Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
68bbe66
test(e2e): shared start retry on network errors + deeplink OPENED-abs…
kherembourg Jul 20, 2026
c881563
test(e2e): iOS modal dismissible + interactive swipe coverage
kherembourg Jul 20, 2026
ef3f2e2
test(e2e): document modal-dismissible evidence coupling for CI arbitr…
kherembourg Jul 20, 2026
aa1ed82
test(e2e): re-display keeps the preloaded presentation source
kherembourg Jul 20, 2026
54ee512
test(e2e): flow display and dismiss coverage (S2)
kherembourg Jul 20, 2026
07b35dd
test(e2e): harden re-display identity with event-stream cross-check
kherembourg Jul 20, 2026
f79a387
test(e2e): iOS interceptor failed/notHandled via real navigate tap (S…
kherembourg Jul 20, 2026
7a70bbd
test(e2e): StoreKit config + iOS purchase/restore suite (S7)
kherembourg Jul 20, 2026
0eccb60
test(e2e): drop cleanup || true and fix stale StoreKit comments
kherembourg Jul 20, 2026
e1db3b7
test(e2e): assert interceptor-actions screen load-proof after preload
kherembourg Jul 20, 2026
a092817
ci(e2e): per-suite timeout with kill+retry, suite markers, wire all s…
kherembourg Jul 20, 2026
069d0af
ci(e2e): kill process group on suite timeout and narrow the StoreKit …
kherembourg Jul 20, 2026
9c831d5
ci(e2e): brace SUITE_LOG expansion crashed by unicode ellipsis under …
kherembourg Jul 20, 2026
7cad7e3
ci(e2e): force-stop the launcher's stuck ANR dialog stealing driver f…
kherembourg Jul 20, 2026
6e74d95
ci(e2e): gate storekit suite on explicit Dart result marker (Greptile…
kherembourg Jul 21, 2026
ef15c49
ci(e2e): review polish — swipe fallback geometry, expanded reporter, …
kherembourg Jul 21, 2026
4e09a79
test(e2e): widen storekit deadlines for cold CI simulators
kherembourg Jul 21, 2026
f021dd9
fix(ios): reference Foundation.framework via SDKROOT, not a pinned SD…
kherembourg Jul 21, 2026
737316b
fix(e2e): make iOS review gates reliable
kherembourg Jul 21, 2026
220cf8a
fix(e2e): stop StoreKit host on result
kherembourg Jul 21, 2026
892e0be
fix(ci): bound Android emulator shutdown
kherembourg Jul 21, 2026
dfbb4c6
perf(e2e): batch iOS suites and run nightly
kherembourg Jul 21, 2026
c655a6f
fix(e2e): synchronize iOS host drivers
kherembourg Jul 21, 2026
951362d
fix(e2e): avoid StoreKit accessibility deadlock
kherembourg Jul 21, 2026
0da2ee8
fix(e2e): tap StoreKit CTA by screen coordinate
kherembourg Jul 21, 2026
1e55332
perf(e2e): support targeted iOS diagnostics
kherembourg Jul 21, 2026
aa277c2
fix(e2e): drive StoreKit CTA with HID press
kherembourg Jul 21, 2026
bbf532a
test(e2e): separate StoreKit from UI interception
kherembourg Jul 21, 2026
8f61f5e
fix(e2e): preserve StoreKit dialog suppression
kherembourg Jul 21, 2026
3ec73b6
test(e2e): seed StoreKit transaction before restore
kherembourg Jul 21, 2026
ab8ff03
test(e2e): bound StoreKit transaction seeding
kherembourg Jul 21, 2026
26cb93b
test(e2e): verify bounded StoreKit restore degradation
kherembourg Jul 21, 2026
7318348
test(e2e): accept bounded StoreKit restore timeout
kherembourg Jul 21, 2026
040f0b7
test(e2e): assert StoreKit verification details
kherembourg Jul 22, 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"
51 changes: 37 additions & 14 deletions .github/workflows/e2e-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,14 @@ 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:
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 Down Expand Up @@ -55,7 +45,16 @@ jobs:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.x"
# 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 @@ -87,6 +86,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 @@ -100,7 +107,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 @@ -112,6 +131,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
44 changes: 25 additions & 19 deletions .github/workflows/e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,28 @@ 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:
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 Down Expand Up @@ -109,6 +105,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
36 changes: 30 additions & 6 deletions purchasely/example/integration_test/deeplink_cold_start_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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';
const String kColdStartDeeplink = 'ply://ply/placements/$kPlacementAudiences';
Expand All @@ -42,11 +44,19 @@ void main() {
// DEEPLINK_OPENED -> PRESENTATION_LOADED -> PRESENTATION_VIEWED.
final order = <PLYEventName>[];
final byName = <PLYEventName, PLYEvent>{};
const tracked = {
const expectedLifecycle = {
PLYEventName.DEEPLINK_OPENED,
PLYEventName.PRESENTATION_LOADED,
PLYEventName.PRESENTATION_VIEWED,
};
// PRESENTATION_OPENED is watched too, but it is NOT part of the
// lifecycle we wait for — it must never fire for a deeplink-only open
// (that event is reserved for in-paywall action buttons opening
// another presentation). See assertion 5 below.
const tracked = {
...expectedLifecycle,
PLYEventName.PRESENTATION_OPENED,
};

// Subscribe BEFORE start: the cold-start deeplink resolves right after the
// SDK configures, so the listener must be live to avoid missing the burst.
Expand All @@ -59,18 +69,20 @@ void main() {

// The whole point of the feature: the deeplink is passed to the builder,
// NOT replayed by a manual Purchasely.handleDeeplink(...) call.
final configured = await Purchasely.apiKey(kApiKey)
final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey)
.runningMode(PLYRunningMode.full)
.logLevel(PLYLogLevel.debug)
.allowDeeplink(true)
.handleDeeplink(kColdStartDeeplink)
.stores([PLYStore.google]).start();
.stores([PLYStore.google]).start());
expect(configured, isTrue,
reason: 'SDK should configure against the real backend');

// Poll until the full chain arrived (network fetch + render take a moment).
// Poll until the full lifecycle arrived (network fetch + render take a
// moment). PRESENTATION_OPENED is deliberately excluded from the wait
// condition since it must never fire.
final sw = Stopwatch()..start();
while (byName.length < tracked.length &&
while (!expectedLifecycle.every(byName.containsKey) &&
sw.elapsed < const Duration(seconds: 60)) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
Expand All @@ -79,7 +91,7 @@ void main() {
'${order.map((e) => e.toString().split('.').last).join(' → ')}');

// 1. All three lifecycle events fired.
expect(byName.keys.toSet(), containsAll(tracked),
expect(byName.keys.toSet(), containsAll(expectedLifecycle),
reason: 'cold-start deeplink must produce the full lifecycle '
'{DEEPLINK_OPENED, PRESENTATION_LOADED, PRESENTATION_VIEWED}, '
'got: $order');
Expand Down Expand Up @@ -109,7 +121,19 @@ void main() {
expect(viewed.properties.sdk_version, isNotNull);
expect(viewed.properties.sdk_version, isNotEmpty);

// 5. A deeplink open must NOT emit PRESENTATION_OPENED — that event is
// reserved for an in-paywall action button opening another
// presentation, not for the SDK auto-opening one via a deeplink.
// Give the asynchronous event channel a short settle window after
// VIEWED; checking immediately at the first complete lifecycle could
// otherwise false-pass if a forbidden event arrived just afterward.
await Future<void>.delayed(const Duration(seconds: 2));
expect(order.contains(PLYEventName.PRESENTATION_OPENED), isFalse,
reason: 'a deeplink open must not emit PRESENTATION_OPENED');

Purchasely.stopListeningToEvents();
await Purchasely.closeAllScreens();
await Future<void>.delayed(const Duration(seconds: 1));
});
});
}
Loading
Loading