Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
96959a2
test(e2e): shared start retry on network errors + deeplink OPENED-abs…
kherembourg Jul 20, 2026
a742dd3
test(e2e): iOS modal dismissible + interactive swipe coverage
kherembourg Jul 20, 2026
a0d1665
test(e2e): document modal-dismissible evidence coupling for CI arbitr…
kherembourg Jul 20, 2026
3b9a5af
test(e2e): re-display keeps the preloaded presentation source
kherembourg Jul 20, 2026
2c1349f
test(e2e): flow display and dismiss coverage (S2)
kherembourg Jul 20, 2026
da1981b
test(e2e): harden re-display identity with event-stream cross-check
kherembourg Jul 20, 2026
4f9bde5
test(e2e): iOS interceptor failed/notHandled via real navigate tap (S…
kherembourg Jul 20, 2026
1b92001
test(e2e): StoreKit config + iOS purchase/restore suite (S7)
kherembourg Jul 20, 2026
c4f7ed2
test(e2e): drop cleanup || true and fix stale StoreKit comments
kherembourg Jul 20, 2026
90123d7
test(e2e): assert interceptor-actions screen load-proof after preload
kherembourg Jul 20, 2026
9cb6666
ci(e2e): per-suite timeout with kill+retry, suite markers, wire all s…
kherembourg Jul 20, 2026
095d5bb
ci(e2e): kill process group on suite timeout and narrow the StoreKit …
kherembourg Jul 20, 2026
910edb4
ci(e2e): brace SUITE_LOG expansion crashed by unicode ellipsis under …
kherembourg Jul 20, 2026
81122b7
ci(e2e): force-stop the launcher's stuck ANR dialog stealing driver f…
kherembourg Jul 20, 2026
73a1756
ci(e2e): gate storekit suite on explicit Dart result marker (Greptile…
kherembourg Jul 21, 2026
a0d7c8e
ci(e2e): review polish — swipe fallback geometry, expanded reporter, …
kherembourg Jul 21, 2026
c5eb56c
test(e2e): widen storekit deadlines for cold CI simulators
kherembourg Jul 21, 2026
4b090d1
fix(ios): reference Foundation.framework via SDKROOT, not a pinned SD…
kherembourg Jul 21, 2026
418b73c
fix(e2e): make iOS review gates reliable
kherembourg Jul 21, 2026
f2b6889
fix(e2e): stop StoreKit host on result
kherembourg Jul 21, 2026
f6aead6
fix(ci): bound Android emulator shutdown
kherembourg Jul 21, 2026
e4fba3b
perf(e2e): batch iOS suites and run nightly
kherembourg Jul 21, 2026
a887c6d
fix(e2e): synchronize iOS host drivers
kherembourg Jul 21, 2026
1516ed5
fix(e2e): avoid StoreKit accessibility deadlock
kherembourg Jul 21, 2026
06e92a0
fix(e2e): tap StoreKit CTA by screen coordinate
kherembourg Jul 21, 2026
db180dc
perf(e2e): support targeted iOS diagnostics
kherembourg Jul 21, 2026
d4d7112
fix(e2e): drive StoreKit CTA with HID press
kherembourg Jul 21, 2026
4f8949b
test(e2e): separate StoreKit from UI interception
kherembourg Jul 21, 2026
236eac4
fix(e2e): preserve StoreKit dialog suppression
kherembourg Jul 21, 2026
8e82be5
test(e2e): seed StoreKit transaction before restore
kherembourg Jul 21, 2026
9e3ae09
test(e2e): bound StoreKit transaction seeding
kherembourg Jul 21, 2026
3b6fa38
test(e2e): verify bounded StoreKit restore degradation
kherembourg Jul 21, 2026
b28baa8
test(e2e): accept bounded StoreKit restore timeout
kherembourg Jul 21, 2026
9e0d1ef
test(e2e): assert StoreKit verification details
kherembourg Jul 22, 2026
abb0008
ci(e2e): keep the pull_request trigger, and wire #153's two suites in
kherembourg Sep 7, 2026
d3c3064
ci(e2e): raise the job budget to 90 minutes
kherembourg Sep 7, 2026
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
19 changes: 19 additions & 0 deletions .github/scripts/ci-adb-wrapper/adb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash
# Work around Emulator 36.6.11 / Platform Tools cleanup hangs in
# reactivecircus/android-emulator-runner. The real adb prints a successful
# response to `emu kill` but can keep its client process alive indefinitely.
# Bound only that command; every other adb invocation is exec'd unchanged.
set -uo pipefail

REAL_ADB="${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}/platform-tools/adb"

if [ "$#" -ge 2 ] && [ "${*: -2:1}" = "emu" ] && [ "${*: -1}" = "kill" ]; then
timeout 15s "$REAL_ADB" "$@"
status=$?
if [ "$status" -eq 124 ]; then
echo "[ci-adb-wrapper] adb emu kill did not exit after 15s; bounded cleanup"
fi
exit "$status"
fi

exec "$REAL_ADB" "$@"
34 changes: 34 additions & 0 deletions .github/scripts/stop-ci-android-emulator.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/bash
# Stop only the AVD owned by this workflow. `adb emu kill` has already been
# attempted by android-emulator-runner; this handles the observed case where
# QEMU remains alive after acknowledging that command.
set -uo pipefail

pattern='qemu-system-x86_64-headless.*-avd test'

if ! pgrep -f "$pattern" >/dev/null; then
echo "[stop-ci-android-emulator] emulator process already stopped"
exit 0
fi

echo "[stop-ci-android-emulator] terminating stuck test AVD process"
pkill -TERM -f "$pattern"

for _ in $(seq 1 10); do
if ! pgrep -f "$pattern" >/dev/null; then
echo "[stop-ci-android-emulator] emulator process stopped"
exit 0
fi
sleep 1
done

echo "[stop-ci-android-emulator] forcing stuck test AVD process to stop"
pkill -KILL -f "$pattern" 2>/dev/null || true
sleep 1

if pgrep -f "$pattern" >/dev/null; then
echo "[stop-ci-android-emulator] test AVD process survived SIGKILL"
exit 1
fi

echo "[stop-ci-android-emulator] emulator process force-stopped"
69 changes: 48 additions & 21 deletions .github/workflows/e2e-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,22 @@ name: E2E Android
# nightly.
#
# Triggers:
# * pull_request — run on PRs targeting main while the v6 migration is in
# flight, so the E2E suite gates the merge. Scoped to
# paths that affect the bridge / native layer / tests.
# * workflow_dispatch — manual run (any branch the workflow exists on)
# * schedule — nightly at 03:00 UTC (GitHub only fires schedules from the
# * workflow_dispatch — explicit manual run (any branch the workflow exists on)
# * schedule — nightly at 05:00 UTC (GitHub only fires schedules from the
# repository's DEFAULT branch, so the nightly run activates
# once this file is merged to main).
on:
# Kept on purpose. This PR originally dropped the PR trigger because it was added
# "while the v6 migration is in flight"; experience since says otherwise. On #153 the
# PR-triggered E2E caught a deterministic 3/3 failure (a listener never firing because
# appHandlesRedemptionAlert defaults to false, so the SDK waits for a popin nobody
# acknowledges) that no unit test and no local run saw. Without it, that class of bug
# only surfaces on the nightly, i.e. after merge.
pull_request:
branches: [main]
paths:
- "purchasely/lib/**"
- "purchasely/android/**"
- "purchasely/example/integration_test/**"
- ".github/workflows/e2e-android.yml"
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
- cron: "0 5 * * *"

concurrency:
group: e2e-android-${{ github.ref }}
Expand All @@ -33,7 +31,12 @@ jobs:
e2e-android:
name: E2E Android (real backend)
runs-on: ubuntu-latest
timeout-minutes: 60
# 90, not 60. This branch consolidated the iOS suites into batches precisely to fit
# the old budget, and #153 added two more suites on top. A single flaky app launch
# costs a 600s per-suite timeout plus a retry, and one such retry on an early batch
# is enough to run the job out of wall clock before the last suite starts — which is
# how this looked like a suite failure when the suite had simply never been reached.
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@v7
Expand All @@ -52,18 +55,18 @@ jobs:
java-version: "17"
cache: gradle

# 3.44.0, NOT 3.24.x. The AGP 9 / Gradle 9 migration (PR #130) bumped the
# Android jobs in ci.yml to 3.44.0 and missed this workflow, so every
# E2E Android run since then has died at Gradle startup with
# flutter.groovy: 8: unable to resolve class groovy.xml.QName
# from Flutter 3.24.5's own Gradle plugin, which Gradle 9 no longer puts
# groovy.xml on the classpath for. The app never built, so each suite then
# burned the integration_test harness's 12-minute timeout waiting for an
# app that was never installed, and the job hit its 60-minute cap.
# e2e-ios.yml is on 3.41.4 for the same class of reason.
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
# 3.44.0: matches the post-AGP9 toolchain (#130) the example app's
# Android build now requires (Gradle 9.1.0 / AGP 9.0.1). The
# previous "3.24.x" pin bundled a flutter.groovy that imports
# groovy.xml.QName, an API Groovy 4 (Gradle 9) no longer exposes —
# compileGroovy failed on every attempt, surfaced only via
# flutter_tools' generic 12-minute per-test timeout (see Task 7's
# CI-hang diagnosis). This is the one workflow file #130 forgot to
# touch; see ci.yml's "Flutter 3.44 / AGP 9 Compatibility" job,
# which already validates this exact combination.
flutter-version: "3.44.0"
channel: stable
cache: true
Expand Down Expand Up @@ -96,6 +99,14 @@ jobs:
~/.android/adb*
key: avd-34-google_apis-x86_64-pixel_6

# Emulator 36.6.11 can print a successful response to `adb emu kill`
# while the adb client itself never exits. android-emulator-runner awaits
# that client during cleanup, so a cold-cache job otherwise stalls until
# the 60-minute job timeout. Prepend a wrapper that bounds ONLY this adb
# subcommand; normal Flutter/Gradle adb operations remain unmodified.
- name: Bound emulator shutdown command
run: echo "$GITHUB_WORKSPACE/.github/scripts/ci-adb-wrapper" >> "$GITHUB_PATH"

- name: Create AVD + snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
Expand All @@ -109,7 +120,19 @@ jobs:
disable-animations: true
script: echo "Generated AVD snapshot for caching."

- name: Ensure cache emulator process exited
if: steps.avd-cache.outputs.cache-hit != 'true'
run: bash .github/scripts/stop-ci-android-emulator.sh

- name: Run E2E suite on emulator
# Step-level ceiling below the 60min job timeout: ci_run_e2e.sh's own
# per-suite watchdog (600s x 3 attempts x 12 suites) already fails
# fast on any single hang, but this bounds the WHOLE step so that,
# even in a worst case, the "Upload E2E logs" step (which runs
# `if: always()`) still gets a chance to preserve the per-suite logs
# instead of the entire job (including that upload) being killed at
# the 60min job-level cutoff with nothing salvaged.
timeout-minutes: 50
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
Expand All @@ -121,6 +144,10 @@ jobs:
disable-animations: true
script: bash purchasely/example/integration_test/tools/ci_run_e2e.sh emulator-5554

- name: Ensure test emulator process exited
if: always()
run: bash .github/scripts/stop-ci-android-emulator.sh

- name: Upload E2E logs
if: always()
uses: actions/upload-artifact@v7
Expand Down
55 changes: 37 additions & 18 deletions .github/workflows/e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,36 @@ name: E2E iOS
# iOS Simulator. These are NOT part of the PR-gating `ci.yml` (they need a
# simulator and real network) — they run on demand and nightly.
#
# Suites:
# 1/3 — dart_ios_bridge_test.dart (T1–T20, no native interaction)
# 2/3 — interceptor_trigger_ios_test (purchase interceptor; driver taps
# ply_action_purchase_* via idb)
# 3/3 — default_dismiss_handler_ios (deeplink + default dismiss; driver
# taps ply_action_close via idb)
# Execution:
# 7 app launches — compatible suites are batched to avoid rebuilding and
# reinstalling the same app for every Dart test file.
#
# Triggers:
# * pull_request — run on PRs targeting main while the v6 migration is in
# flight, so the E2E suite gates the merge. Scoped to
# paths that affect the bridge / native layer / tests.
# * workflow_dispatch — manual run (any branch the workflow exists on)
# * schedule — nightly at 04:00 UTC (GitHub only fires schedules from
# * workflow_dispatch — explicit manual run (any branch the workflow exists on)
# * schedule — nightly at 05:00 UTC (GitHub only fires schedules from
# the repository's DEFAULT branch, so the nightly run
# activates once this file is merged to main).
on:
# Kept on purpose. This PR originally dropped the PR trigger because it was added
# "while the v6 migration is in flight"; experience since says otherwise. On #153 the
# PR-triggered E2E caught a deterministic 3/3 failure (a listener never firing because
# appHandlesRedemptionAlert defaults to false, so the SDK waits for a popin nobody
# acknowledges) that no unit test and no local run saw. Without it, that class of bug
# only surfaces on the nightly, i.e. after merge.
pull_request:
branches: [main]
paths:
- "purchasely/lib/**"
- "purchasely/ios/**"
- "purchasely/example/integration_test/**"
- ".github/workflows/e2e-ios.yml"
workflow_dispatch:
inputs:
suite:
description: Suite to run (use StoreKit only for targeted diagnostics)
required: true
default: all
type: choice
options:
- all
- storekit
schedule:
- cron: "0 4 * * *"
- cron: "0 5 * * *"

concurrency:
group: e2e-ios-${{ github.ref }}
Expand All @@ -39,7 +43,12 @@ jobs:
e2e-ios:
name: E2E iOS (real backend)
runs-on: macos-latest
timeout-minutes: 60
# 90, not 60. This branch consolidated the iOS suites into batches precisely to fit
# the old budget, and #153 added two more suites on top. A single flaky app launch
# costs a 600s per-suite timeout plus a retry, and one such retry on an early batch
# is enough to run the job out of wall clock before the last suite starts — which is
# how this looked like a suite failure when the suite had simply never been reached.
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@v7
Expand Down Expand Up @@ -109,6 +118,16 @@ jobs:
xcrun simctl bootstatus "$UDID" -b

- name: Run E2E suite on iOS Simulator
# Step-level ceiling below the 60min job timeout: ci_run_e2e_ios.sh's
# own watchdogs (300s for Flutter batches, 600s for StoreKit) already
# fails fast on any single hang, but this bounds the WHOLE step so
# that, even in a worst case, the "Collect simulator crash logs" and
# "Upload E2E logs" steps (which run on failure/always) still get a
# chance to preserve diagnostics instead of the entire job being
# killed at the 60min job-level cutoff with nothing salvaged.
timeout-minutes: 35
env:
E2E_IOS_SUITE: ${{ inputs.suite || 'all' }}
run: bash purchasely/example/integration_test/tools/ci_run_e2e_ios.sh ${{ steps.boot-sim.outputs.udid }}

- name: Collect simulator crash logs (on failure)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:purchasely_flutter/purchasely_flutter.dart';

import 'helpers/e2e_start.dart';

const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87';
const String kPlacementAudiences = 'integration_test_audiences';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

setUpAll(() async {
final configured = await Purchasely.apiKey(kApiKey)
final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey)
.runningMode(PLYRunningMode.full)
.logLevel(PLYLogLevel.debug)
.stores([PLYStore.google]).start();
.stores([PLYStore.google]).start());
expect(configured, isTrue,
reason: 'SDK should configure against the real backend');
});
Expand Down
34 changes: 17 additions & 17 deletions purchasely/example/integration_test/dart_ios_bridge_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//
// Tests requiring a host driver (T8, T9):
// T8 — (bash integration_test/tools/tap_purchase_ios.sh &) # idb tap
// T9 — (bash integration_test/tools/swipe_dismiss_ios.sh &) # idb swipe
// T9 — (bash integration_test/tools/close_paywall_ios.sh &) # idb swipe
//
// Run with:
// flutter test integration_test/dart_ios_bridge_test.dart \
Expand All @@ -16,6 +16,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:purchasely_flutter/purchasely_flutter.dart';

import 'helpers/e2e_start.dart';

const String kApiKey = '0ad0594b-3b3d-4fea-8ee1-4b5df91efe87';
const String kPlacementAudiences = 'integration_test_audiences';

Expand All @@ -24,20 +26,14 @@ void main() {

setUpAll(() async {
debugPrint('SETUP → calling Purchasely.start()…');
bool configured = false;
try {
configured = await Purchasely.apiKey(kApiKey)
.runningMode(PLYRunningMode.full)
.logLevel(PLYLogLevel.debug)
.storekitVersion(PLYStorekitVersion.storeKit2)
.start()
.timeout(const Duration(seconds: 120),
onTimeout: () =>
throw StateError('Purchasely.start() timed out after 120s'));
} catch (e) {
debugPrint('SETUP → start() error: $e');
rethrow;
}
final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey)
.runningMode(PLYRunningMode.full)
.logLevel(PLYLogLevel.debug)
.storekitVersion(PLYStorekitVersion.storeKit2)
.start()
.timeout(const Duration(seconds: 120),
onTimeout: () =>
throw StateError('Purchasely.start() timed out after 120s')));
debugPrint('SETUP → configured=$configured');
expect(configured, isTrue,
reason: 'SDK should configure against the real backend');
Expand Down Expand Up @@ -189,7 +185,7 @@ void main() {
// Covered by integration_test/interceptor_trigger_ios_test.dart

// T9 — Default dismiss handler + deeplink + swipe-dismiss
// Host driver: integration_test/tools/swipe_dismiss_ios.sh (idb swipe)
// Host driver: integration_test/tools/close_paywall_ios.sh (idb swipe)
// Covered by integration_test/default_dismiss_handler_ios_test.dart

// T10 — addEventListener → PRESENTATION_VIEWED
Expand Down Expand Up @@ -320,7 +316,11 @@ void main() {
presentation.display(const PLYTransition.fullScreen());

await Future<void>.delayed(const Duration(seconds: 3));
await presentation.close();
await presentation.close().timeout(
const Duration(seconds: 20),
onTimeout: () => throw StateError(
'T12 presentation.close() timed out after 20s'),
);
await Future<void>.delayed(const Duration(seconds: 2));

expect(interceptorCalled, isFalse,
Expand Down
Loading
Loading