Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions .github/workflows/device-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions tests/Plugin.AdMob.DeviceTests/AdLoadHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
internal static class AdLoadHarness
{
Expand Down
39 changes: 30 additions & 9 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,52 @@ 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:

- `SUMMARY_BANNER` → **hard gate** on any runner.
- `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
Expand Down
21 changes: 17 additions & 4 deletions tests/ci/run-android-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ 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"
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"

Expand Down Expand Up @@ -49,11 +60,13 @@ echo "all: ${allline:-<none>}"

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

Expand Down
Loading