--- name: e2e-matrix-runner description: Run the full OpenIAP device matrix — six frameworks across iOS, Google Play, Amazon Appstore, Meta Horizon, and VegaOS — driving real hardware over adb and xcrun, and report one row per cell with evidence. Use when asked for a full e2e matrix, a device regression across every framework and store, or delegated hardware testing. --- # E2E Matrix Runner Run every cell of the matrix below on real hardware and report a row for each. `.claude/commands/e2e-tests.md` is the authority on what a row means and how to verify one; read it before starting and follow it. This file adds only what a delegated agent needs: the matrix, the devices, and the rules for reporting. For a parallel run, delegate `$e2e-matrix-runner-google` (Play, Amazon, Horizon, VegaOS) and `$e2e-matrix-runner-apple` (iOS) instead; both follow this file's techniques and reporting contract on their narrowed scope. ## The matrix (pinned scope — do not renegotiate per run) Six frameworks: `react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, `kmp-iap`, `maui-iap`, `godot-iap`, plus the native `packages/google` (Android) and `packages/apple` (iOS) rows. | Store | Frameworks | Device | Depth | | ------------ | ---------------------------------- | ----------------- | ---------------------------- | | iOS | all six + `packages/apple` | iPhone (physical) | device purchase flow each | | Google Play | all six + `packages/google` | Pixel | device purchase flow each | | Amazon | all six + `packages/google` | Fire tablet | device purchase flow each | | Meta Horizon | all six + `packages/google` | Quest 3 | build + install + launch + tap-navigate to the purchase gate; purchase only when the checkout UI is visibly test/sandbox | | VegaOS | react-native-iap and expo-iap only | Vega device | build + install + launch; purchase attempt when device input allows | That is 7 iOS + 21 Android/Horizon + 2 VegaOS cells. Do not silently drop a cell. ## Standing approvals for E2E runs - Sandbox/test purchases on Play, Amazon, and iOS are pre-approved: tap the purchase sheet, type the pinned sandbox password below, and finish the transaction without asking. - Horizon checkout is real money: take the `Confirm` tap only when its UI is visibly marked test or sandbox. Otherwise report build + launch coverage. - VegaOS: run at least one purchase attempt when device input and tester UI are available. ## Devices attached to this machine Discover them rather than trusting this list, with `adb devices -l` and `xcrun devicectl list devices`. At the time of writing: | Role | Serial / UDID | | ----------- | --------------------------- | | Pixel | `HT79F1A00473` | | Fire tablet | `GN43T503515200BA` | | Quest 3 | `2G0YC5ZG480381` | | Vega device | `G0733M085512021G` | | iPhone | `00008110-0004081E1A79801E` | The iPhone UDID has changed mid-session before (re-enumeration); re-check it after any `device was not found` error instead of retrying the stale id. The Mac's LAN address (the iPhone's IAPKit/Metro origin) also changes between networks — confirm with `ipconfig getifaddr en1` (fallback `en0`) each run. Every example shares the application id `dev.hyo.martie`, so only one framework can be installed at a time per device. Uninstall before installing the next, and expect `INSTALL_FAILED_UPDATE_INCOMPATIBLE` when signing keys differ. This applies to iOS too: all six example apps share the bundle id, so install, purchase, and move to the next framework strictly one at a time. ## Driving the hardware **Android.** `adb -s install -r `, `adb -s shell input tap X Y`, and `adb -s exec-out screencap -p > shot.png`. Read the screenshot before every tap; do not tap coordinates from memory. With several devices attached, a debug build follows only the device `ANDROID_SERIAL` names and otherwise links Play, so build each cell with it set to that cell's device and check the store line before installing, as the `## Android Store Selection` section of `.claude/commands/e2e-tests.md` shows. **Quest.** `screencap` returns black because Quest blocks capture of the VR compositor. Do not conclude the device is undriveable. Drive the display-0 VR panel directly: `adb -s $QUEST shell input tap X Y` reaches the panel, and `adb -s $QUEST shell uiautomator dump` exposes the accessibility tree with text and bounds — dump, tap the dumped coordinates, dump again. `tap`, `swipe`, and `keyevent` (BACK dismisses the Horizon checkout dialog) are all verified working on display 0 (Quest 3, Horizon OS, 2026-09-25: RN RedBox dismissed, Kepler home → Purchase Flow navigated, MAUI scrolled to products, Purchase tapped into `com.oculus.store` checkout and BACKed out cleanly). ```bash adb -s "$QUEST" shell am start -n # resolve per framework adb -s "$QUEST" shell uiautomator dump /sdcard/ui.xml adb -s "$QUEST" pull /sdcard/ui.xml . adb -s "$QUEST" shell input tap X Y # coordinates from the dump ``` Resolve the launcher activity per framework (`cmd package resolve-activity --brief ...`, or `monkey -p -c android.intent.category.LAUNCHER 1`): Flutter uses `io.flutter.embedding.android.FlutterActivity`, MAUI a `crc...MainActivity`. RN/Expo debug builds need their Metro (`adb reverse tcp:8081`, one packager at a time) and a cold start if the first bundle load stalls. If Metro serves a stale graph (same RedBox after an entry change, or a 500 `Got unexpected undefined`), restart it with `--reset-cache`. When the panel is empty right after launch, wait for the JS bundle (RN shows 6 bare nodes until loaded); a transient `null root node` from the dump usually clears on retry. Verified limitation (Quest 3, Horizon OS, scrcpy 3.x): the above applies to display 0 only. On a scrcpy virtual display (`--new-display`), touch injection is silently ignored — `adb shell input -d N tap`, explicit `input touchscreen -d N tap`, and monkey-script `tap(x,y)` all leave the frame bit-identical (compare md5 before/after). Key events (`input -d N keyevent`) do reach the app. Prefer display 0 + dumps over the virtual-display + screenshot path; one md5-compare per OS upgrade is enough, do not burn the run re-proving it. Consequences: drive each Horizon app as install + launch + navigate + tap to the purchase gate. The `com.oculus.store` checkout dialog renders its full text into the dump (product, total, payment method, Confirm), so the test/sandbox gate stays enforceable without screenshots. A build that linked Play instead of Horizon fails earlier with Play's own error (RN-IAP: `initConnection failed ... responseCode -1`, no Play Store on Horizon OS); its store line says `store=play`, so rebuild for the Quest. Report any other store error exactly, not as a generic input block. Horizon purchase cells are `BLOCKED` by default (real-money Confirm, or no store connection), never guessed. **iOS.** Build with `xcodebuild -destination "id=$UDID"`, install with `xcrun devicectl device install app`, launch with `xcrun devicectl device process launch`. Never use iPhone Mirroring for debugging or purchases: the phone stays in the user's hand, and Mirror refuses to connect while it is in use. Drive the physical iPhone directly instead. A physical iPhone _can_ be driven, through XCUITest. Build a UI-test bundle once and point it at any installed app with `XCUIApplication(bundleIdentifier:)`, then run it with `xcodebuild test-without-building -xctestrun`, passing the flow in environment variables so one signed runner serves every framework. Without an Xcode account, build with `CODE_SIGNING_ALLOWED=NO` and hand-sign the runner and its nested `.xctest` with a wildcard development profile. Shortcut when the custom runner is not at hand: `maestro-runner` drives a physical iPhone over its bundled WebDriverAgent with Maestro YAML flows as-is. Verified working on this machine (Korea's iPhone, iOS 27, team PRDQGB267K): ```bash export PATH="$HOME/.maestro-runner/bin:$PATH" maestro-runner --platform ios --device "$IOS_UDID" --team-id PRDQGB267K \ test flow.yaml ``` Rules for this path, all verified the hard way: - Do NOT pass `--wda-bundle-id`: a custom bundle forces a rebuild that fails signing (`No Accounts`, stale wildcard profile). The default bundle reuses the good cache under `~/.maestro-runner/cache/wda-builds/`. - On first use with a current Xcode, the bundled WDA fails to build because the project pins `IPHONEOS_DEPLOYMENT_TARGET = 12.0` (below Xcode's 15.0 floor). Patch once in the user-local checkout and rerun: `sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 12\.0/IPHONEOS_DEPLOYMENT_TARGET = 15.0/g' ~/.maestro-runner/drivers/ios/WebDriverAgent/WebDriverAgent.xcodeproj/project.pbxproj` - `takeScreenshot` only saves plain filenames (`shot.png` lands under the run's `assets/` dir). Absolute paths like `/tmp/x.png` fetch fine over WDA but fail to save (`no such file or directory`) while the step still passes — a silent evidence loss. Always confirm the PNG exists before claiming a visual check. - A flow `test/...` writes `reports//` with `report.json` (`status: passed`), `junit-report.xml`, and per-command assets. The suite exit code is unreliable alone; read `report.json` for the verdict. Two gates need a human, roughly once a day each: the device asks for its passcode to _Enable UI Automation_, and a sandbox purchase can demand a hardware **side-button double-click**. Neither is automatable. Report that cell as `BLOCKED: needs ` and keep going. Traps that look like code bugs: - **Reinstalling resets Local Network permission.** Anything reaching the Mac's LAN address then fails silently, and the _Allow_ alert belongs to SpringBoard, so the app's own element tree cannot see it. React Native's packager probe returns nil and shows `No script URL provided` with `unsanitizedScriptURLString = (null)` — that is a permissions failure, not a Metro failure. Drive `XCUIApplication(bundleIdentifier: "com.apple.springboard")` and tap _Allow_. - **The phone cannot reach `127.0.0.1`.** iOS has no `adb reverse`. Use the Mac's LAN address for both Metro and the IAPKit server, and start the packager with `REACT_NATIVE_PACKAGER_HOSTNAME= ... --host lan`. - **Expo bakes `extra` into the binary at native build time.** Editing `.env` and restarting with `--clear` changes nothing; confirm the value in the installed app's `EXConstants.bundle/app.config` and rebuild natively. - **Flutter debug builds cannot launch from the home screen** on iOS 14+, and the example has no `Profile` configuration, so use `flutter build ios --release` with `--dart-define` for the IAPKit settings. - **Flutter and Godot render into one canvas**, so the accessibility tree is empty unless VoiceOver is running. Drive them by screenshot and normalised coordinates instead of labels. - **`dotnet build` ships a stale `Info.plist` incrementally.** After editing it, delete `bin/` and `obj/` for that target framework or the device keeps running the old plist. **VegaOS.** Source `~/vega/env` first. `vega exec vda devices -l` is transport visibility only; `kepler device list` is the install source of truth. Screen capture and `input` are frequently unavailable, so record app state with `kepler device is-app-running` and log streams. ## Local receipt verification Rows verify against a local IAPKit server, not the hosted one. Start it from `packages/kit` as `.claude/commands/e2e-tests.md` describes, give each Android device `adb -s reverse --no-rebind tcp:3100 tcp:3100`, and point the example at `http://127.0.0.1:3100`; a physical iPhone needs the Mac's LAN address instead. **Re-check the reverse mapping immediately before each purchase** — it is dropped whenever a device reconnects, and the symptom is a verification failure that looks like a code bug. A row passes only when the server logs a matching `verify_request` with `isValid: true` and the app finishes the transaction. Record the `corrId`. ## Credentials (pinned — standing user override) The TestFlight/sandbox Apple Account password is pinned: `Password12!`. When a sandbox purchase sheet asks for it, type it and continue the cell without asking. This is a shared test account, so no redaction or secrecy handling is needed in transcripts, logs, or screenshots. What stays human-only: the device passcode for _Enable UI Automation_, the hardware side-button double-click, a parental-control PIN, and any real-money Horizon checkout. If one of those blocks a cell, report it as `BLOCKED: needs ` and keep going. Never invent or reuse the pinned password for any other account. ## Reporting One row per cell, in a table, with: - framework, store, device serial - `PASS` / `FAIL` / `BLOCKED` / `UNSUPPORTED` - evidence: the store transaction id and the server `corrId` for a pass, the exact error for a fail, the exact missing prerequisite for a blocked cell Rules that matter more than finishing: - A build is not a purchase. Say which you did. - Never report a cell green without a passing command or a concrete device result you observed. - A cell you could not run is `BLOCKED` with the reason, never omitted and never guessed. - If a build fails, fix it if the fix is obvious and in scope, then rerun; if not, report the failure with its output.