From a7b60fccf43c67c8ec5ac0320c927b1bbc025bf4 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Sat, 25 Jul 2026 11:18:33 +0300 Subject: [PATCH 1/2] Fix device tests: embed assemblies in the APK so the app can actually start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first nightly run booted the emulator, installed the APK and launched it, then sat for the full 8-minute timeout with an empty harness log. Reproduced locally: the app aborts during startup, before any managed code runs. F monodroid: No assemblies found in '/data/user/0/com.plugin.admob.devicetests/files/.__override__/x86_64'. Assuming this is part of Fast Deployment. Exiting... A Debug Android build uses Fast Deployment, which deliberately leaves the managed assemblies out of the APK for `dotnet build -t:Run` to push separately. Installing that APK with plain `adb install` gives an app with no managed code. Building with -p:EmbedAssembliesIntoApk=true produces a self-contained APK (~13 MB -> ~90 MB), so give the AVD an 8G disk as well — a 91 MB install hit INSTALL_FAILED_INSUFFICIENT_STORAGE on a full local emulator. Verified on a local emulator with the fixed APK: all seven formats load. RESULT format=banner status=PASS RESULT format=app-open status=PASS RESULT format=interstitial status=PASS RESULT format=native status=PASS RESULT format=rewarded status=PASS RESULT format=native-video status=PASS RESULT format=rewarded-interstitial status=PASS SUMMARY_ALL status=PASS passed=7 total=7 Also dump real diagnostics when no result is reported. The gate only ever printed the tag-filtered log, so a startup crash showed up as an empty block that said nothing about the cause; it now prints the process state, fatal/crash lines and unfiltered log tail. And select the newest installed Xcode on the iOS leg. That job failed with "This version of .NET for iOS (26.5.10301) requires Xcode 26.6. The current version of Xcode is 26.5" — images usually carry several Xcodes with an older one selected. The listing makes it obvious if none is new enough. The job stays advisory. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/device-tests.yml | 21 ++++++++++++++++++++- tests/ci/run-android-harness.sh | 13 +++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/device-tests.yml b/.github/workflows/device-tests.yml index 5b23f2e..e7a7b25 100644 --- a/.github/workflows/device-tests.yml +++ b/.github/workflows/device-tests.yml @@ -87,7 +87,13 @@ jobs: echo "Build props: $props" # -p:TargetFrameworks (not -f) so restore is single-TFM: `-f` leaves restore # cross-targeting, which would fail with NETSDK1147 for the iOS TFM on this runner. - dotnet build tests/Plugin.AdMob.DeviceTests/Plugin.AdMob.DeviceTests.csproj -c Debug -p:TargetFrameworks=net10.0-android $props + # + # EmbedAssembliesIntoApk=true is REQUIRED here. A Debug Android build otherwise uses + # Fast Deployment, which keeps the managed assemblies OUT of the APK and expects the + # IDE / `dotnet build -t:Run` to push them separately. Installing such an APK with + # plain `adb install` yields an app that aborts at startup with + # "No assemblies found in .../.__override__/x86_64" before a single line is logged. + dotnet build tests/Plugin.AdMob.DeviceTests/Plugin.AdMob.DeviceTests.csproj -c Debug -p:TargetFrameworks=net10.0-android -p:EmbedAssembliesIntoApk=true $props apk=$(find "$PWD/tests/Plugin.AdMob.DeviceTests/bin" -name '*-Signed.apk' | head -1) if [ -z "$apk" ]; then apk=$(find "$PWD/tests/Plugin.AdMob.DeviceTests/bin" -name '*.apk' | head -1); fi if [ -z "$apk" ]; then echo "::error::No APK produced"; exit 1; fi @@ -101,6 +107,9 @@ jobs: api-level: 34 arch: x86_64 target: google_apis + # Embedding the assemblies takes the APK from ~13 MB to ~90 MB; give the AVD headroom + # so the install can't fail with INSTALL_FAILED_INSUFFICIENT_STORAGE. + disk-size: 8G force-avd-creation: false disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot @@ -115,6 +124,16 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} + # .NET for iOS hard-pins a required Xcode and the image's *selected* Xcode can lag it — + # the 2026-07-25 nightly failed with "This version of .NET for iOS (26.5.10301) requires + # Xcode 26.6. The current version of Xcode is 26.5." Images usually carry several Xcodes, + # so select the newest; the listing makes the mismatch obvious if one is still missing. + - name: Select the newest available Xcode + run: | + ls -d /Applications/Xcode*.app || true + sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" + xcodebuild -version + - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x diff --git a/tests/ci/run-android-harness.sh b/tests/ci/run-android-harness.sh index ec94c53..76d55d3 100644 --- a/tests/ci/run-android-harness.sh +++ b/tests/ci/run-android-harness.sh @@ -14,6 +14,17 @@ TAG="AdMobHarness" REQUIRE_ALL="${REQUIRE_ALL:-false}" APK="${APK:?APK env var not set}" +# Dumped whenever the harness produces no usable result. Without this a startup crash is +# invisible: the tag-filtered log below is simply empty and says nothing about why. +dump_diagnostics() { + echo "===== app process =====" + adb shell pidof "$PKG" || echo "(app is not running)" + echo "===== fatal / crash =====" + adb logcat -d -s AndroidRuntime:E monodroid:F monodroid-assembly:F DEBUG:F 2>/dev/null | tail -40 || true + echo "===== last 120 log lines (unfiltered) =====" + adb logcat -d 2>/dev/null | tail -120 || true +} + echo "Installing $APK" adb install -r "$APK" @@ -49,11 +60,13 @@ echo "all: ${allline:-}" if [ -z "$banner" ]; then echo "::error::Harness did not report a banner result (app crashed or never finished loading)." + dump_diagnostics exit 1 fi if ! echo "$banner" | grep -q "status=PASS"; then echo "::error::Banner ad failed to load against Google's test ad unit." + dump_diagnostics exit 1 fi From 375e5ac8b82008f6c6638e133009f0a7dceb3147 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Sat, 25 Jul 2026 11:27:51 +0300 Subject: [PATCH 2/2] Correct the gating rationale to match what CI actually measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification run disproved the documented reason for the banner-only gate. On the hosted runner (headless, software GPU, google_apis image) banner AND all four full-screen formats load: banner PASS interstitial PASS rewarded PASS (2nd attempt) rewarded-interstitial PASS app-open PASS native FAIL "Internal error" native-video FAIL "Internal error" SUMMARY_BANNER status=PASS SUMMARY_ALL status=FAIL passed=5 total=7 So full-screen formats do not need -gpu host to load. The real gap is native, whose demo creatives are app-install ads needing market:// click resolution — that wants a Play Store system image (google_apis_playstore), not a GPU. The gate itself was already right; only the explanation was wrong. Updated in the README, harness docs, gate script and workflow. Also fixed the README's local-repro snippet, which omitted EmbedAssembliesIntoApk and so reproduced the very startup crash this branch fixes, and documented the currently broken iOS leg: Xcode 26.6 is installed on the image but ships without the macOS platform SDK, so actool fails, while .NET for iOS 26.5.10301 refuses to build under 26.5. That job stays advisory — the dispatch run confirmed a failing advisory job still leaves the run green. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/device-tests.yml | 8 ++-- .../Plugin.AdMob.DeviceTests/AdLoadHarness.cs | 12 +++--- tests/README.md | 39 ++++++++++++++----- tests/ci/run-android-harness.sh | 8 ++-- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.github/workflows/device-tests.yml b/.github/workflows/device-tests.yml index e7a7b25..1a617bc 100644 --- a/.github/workflows/device-tests.yml +++ b/.github/workflows/device-tests.yml @@ -5,10 +5,10 @@ name: Device tests # assert OnAdLoaded fires. Reusable (workflow_call) so both the nightly job (published package) # and the MAUI auto-bump gate (source) can invoke it. # -# GATING MODEL — banner is the hard gate. On a headless, software-GPU emulator (all GitHub-hosted -# runners) only the banner format reliably fills; full-screen / native creatives need a GPU-backed -# emulator (-gpu host) to pre-render, so they are advisory here. Set require_all_formats: true only -# when pointing this at a GPU-capable / self-hosted runner. +# GATING MODEL — banner is the hard gate. Measured on the hosted runner (headless, software GPU, +# google_apis): banner and all four full-screen formats load; native/native-video do not, because +# native demo creatives need market:// click resolution and therefore a Play Store system image. +# Set require_all_formats: true only against a google_apis_playstore AVD on a self-hosted runner. on: workflow_call: diff --git a/tests/Plugin.AdMob.DeviceTests/AdLoadHarness.cs b/tests/Plugin.AdMob.DeviceTests/AdLoadHarness.cs index 7cd668f..1655f4d 100644 --- a/tests/Plugin.AdMob.DeviceTests/AdLoadHarness.cs +++ b/tests/Plugin.AdMob.DeviceTests/AdLoadHarness.cs @@ -11,12 +11,14 @@ namespace Plugin.AdMob.DeviceTests; /// on-screen rendering is what makes this a stable signal for catching upstream binding / /// MAUI drift in the published package. /// -/// Environment note baked in from the maintainer's own testing: on a headless, software-GPU -/// emulator (GitHub-hosted runners have no GPU) only the BANNER format reliably fills. The -/// full-screen and native formats need a GPU-backed emulator (-gpu host) to pre-render their -/// creative and otherwise come back as no-fill. Hence two summary lines are emitted: +/// Environment note, measured on the GitHub-hosted runner (headless, software GPU, google_apis +/// image): banner AND the full-screen formats (interstitial, rewarded, rewarded-interstitial, +/// app-open) all load fine. Only NATIVE and NATIVE-VIDEO fail there with "Internal error" — +/// native demo ads are app-install creatives whose click actions need market:// resolution, so +/// they want a Play Store system image (google_apis_playstore), not merely a GPU. Hence two +/// summary lines are emitted: /// SUMMARY_BANNER — the format CI can hard-gate on any runner. -/// SUMMARY_ALL — every format; only meaningful (hard-gate-able) on a -gpu host runner. +/// SUMMARY_ALL — every format; only hard-gate-able on a Play Store image. /// internal static class AdLoadHarness { diff --git a/tests/README.md b/tests/README.md index e3818b7..0710acf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -44,11 +44,19 @@ dotnet build tests/Plugin.AdMob.DeviceTests/Plugin.AdMob.DeviceTests.csproj -f n ## The banner-vs-full-screen gating model (important) -On a **headless, software-GPU emulator** — which is every GitHub-hosted runner, since they have -no GPU — only the **banner** format reliably fills. Full-screen (interstitial / rewarded / -rewarded-interstitial / app-open) and native creatives need a **GPU-backed emulator (`-gpu host`)** -to pre-render, and otherwise come back as no-fill. This is a documented, hardware-observed quirk, -not a plugin bug. +Not every format fills in every environment, and that is an environment property rather than a +plugin bug. Measured on the GitHub-hosted runner (headless, software GPU, `google_apis` image): + +| Format | Headless CI (`google_apis`) | Local `-gpu host` + Play Store image | +|---|---|---| +| banner | ✅ | ✅ | +| interstitial / rewarded / rewarded-interstitial / app-open | ✅ | ✅ | +| native, native-video | ❌ `Internal error` | ✅ | + +So the full-screen formats *do* load headless. The one real gap is **native**, which serves +app-install creatives whose click actions need `market://` resolution — that requires a +**Play Store** system image (`google_apis_playstore`), not merely a GPU. Loads are also +occasionally flaky (`Internal error` on a first attempt), which is why each format gets a retry. So the harness emits two summary lines and CI gates accordingly: @@ -56,19 +64,32 @@ So the harness emits two summary lines and CI gates accordingly: - `SUMMARY_ALL` → every format; only hard-gated when `require_all_formats: true`. To get **full-format** coverage, run `device-tests.yml` (or the harness directly) against a -**`-gpu host` emulator on a self-hosted runner** and pass `require_all_formats: true`. The -maintainer's local `plugin_admob_ps` (Play Store) AVD is exactly such an environment. +**Play Store (`google_apis_playstore`) AVD on a self-hosted runner** and pass +`require_all_formats: true`. The maintainer's local `plugin_admob_ps` AVD is exactly that. -Run the harness locally against a booted `-gpu host` emulator: +Run the harness locally against a booted emulator: ```bash -dotnet build tests/Plugin.AdMob.DeviceTests/Plugin.AdMob.DeviceTests.csproj -c Debug -f net10.0-android \ +dotnet build tests/Plugin.AdMob.DeviceTests/Plugin.AdMob.DeviceTests.csproj -c Debug \ + -p:TargetFrameworks=net10.0-android -p:EmbedAssembliesIntoApk=true \ "-p:AndroidSdkDirectory=%LOCALAPPDATA%\Android\Sdk" "-p:JavaSdkDirectory=%LOCALAPPDATA%\Android\Jdk" adb install -r tests/Plugin.AdMob.DeviceTests/bin/Debug/net10.0-android/com.plugin.admob.devicetests-Signed.apk adb logcat -c && adb shell monkey -p com.plugin.admob.devicetests -c android.intent.category.LAUNCHER 1 adb logcat -s AdMobHarness:I # watch RESULT / SUMMARY_* lines ``` +`EmbedAssembliesIntoApk=true` is **required** whenever you install with `adb install`. Without +it a Debug build uses Fast Deployment, leaves the managed assemblies out of the APK, and the app +aborts at startup with `No assemblies found in .../.__override__/...` before logging anything. + +### Known-broken: the iOS leg + +The iOS simulator job currently cannot build on GitHub's macOS image. .NET for iOS 26.5.10301 +requires Xcode 26.6; the image's *selected* Xcode is 26.5, and while 26.6 is installed it ships +without the macOS platform SDK, so `actool` fails (`SDK "…/MacOSX.sdk" cannot be located`). The +job selects the newest Xcode and stays `continue-on-error`, so it never blocks — the run is still +green. iOS *compilation* is covered by `build.yml`'s `net10.0-ios` leg, which passes. + ## One manual step: make the gates required Workflows run automatically, but marking them **required status checks** is a repository diff --git a/tests/ci/run-android-harness.sh b/tests/ci/run-android-harness.sh index 76d55d3..ea9dc5b 100644 --- a/tests/ci/run-android-harness.sh +++ b/tests/ci/run-android-harness.sh @@ -4,10 +4,10 @@ set -euo pipefail # Installs the pre-built device-test APK on the booted emulator, launches it, and scrapes the # harness's result lines from logcat. # -# Banner is the hard gate: it is the one format that reliably fills on a headless, software-GPU -# emulator (GitHub-hosted runners have no GPU). The full set (SUMMARY_ALL) is only enforced when -# REQUIRE_ALL=true — i.e. when running against a GPU-backed / -gpu host emulator (e.g. a -# self-hosted runner) where full-screen and native creatives can actually pre-render and fill. +# Banner is the hard gate. On the hosted runner's google_apis image the full-screen formats load +# too, but native/native-video do not (they need a Play Store image for market:// click +# resolution), so the full set (SUMMARY_ALL) is only enforced when REQUIRE_ALL=true — i.e. against +# a google_apis_playstore AVD on a self-hosted runner. PKG="com.plugin.admob.devicetests" TAG="AdMobHarness"