diff --git a/.github/scripts/ci-adb-wrapper/adb b/.github/scripts/ci-adb-wrapper/adb new file mode 100755 index 00000000..fe583bb9 --- /dev/null +++ b/.github/scripts/ci-adb-wrapper/adb @@ -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" "$@" diff --git a/.github/scripts/stop-ci-android-emulator.sh b/.github/scripts/stop-ci-android-emulator.sh new file mode 100755 index 00000000..70ebc3ec --- /dev/null +++ b/.github/scripts/stop-ci-android-emulator.sh @@ -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" diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml index 455ade51..4e21c94a 100644 --- a/.github/workflows/e2e-android.yml +++ b/.github/workflows/e2e-android.yml @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml index d048c563..df0f6e3a 100644 --- a/.github/workflows/e2e-ios.yml +++ b/.github/workflows/e2e-ios.yml @@ -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 }} @@ -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) diff --git a/purchasely/example/integration_test/dart_android_bridge_test.dart b/purchasely/example/integration_test/dart_android_bridge_test.dart index a05b02ed..8fb6af3a 100644 --- a/purchasely/example/integration_test/dart_android_bridge_test.dart +++ b/purchasely/example/integration_test/dart_android_bridge_test.dart @@ -19,6 +19,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'; @@ -26,10 +28,10 @@ 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'); }); diff --git a/purchasely/example/integration_test/dart_ios_bridge_test.dart b/purchasely/example/integration_test/dart_ios_bridge_test.dart index 6af07faa..b58a5d4a 100644 --- a/purchasely/example/integration_test/dart_ios_bridge_test.dart +++ b/purchasely/example/integration_test/dart_ios_bridge_test.dart @@ -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 \ @@ -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'; @@ -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'); @@ -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 @@ -320,7 +316,11 @@ void main() { presentation.display(const PLYTransition.fullScreen()); await Future.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.delayed(const Duration(seconds: 2)); expect(interceptorCalled, isFalse, diff --git a/purchasely/example/integration_test/deeplink_cold_start_test.dart b/purchasely/example/integration_test/deeplink_cold_start_test.dart index 4c4c7b65..d8848afc 100644 --- a/purchasely/example/integration_test/deeplink_cold_start_test.dart +++ b/purchasely/example/integration_test/deeplink_cold_start_test.dart @@ -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'; @@ -42,11 +44,19 @@ void main() { // DEEPLINK_OPENED -> PRESENTATION_LOADED -> PRESENTATION_VIEWED. final order = []; final byName = {}; - 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. @@ -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.delayed(const Duration(milliseconds: 250)); } @@ -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'); @@ -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.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.delayed(const Duration(seconds: 1)); }); }); } diff --git a/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart b/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart index 6a04b6d6..1462cbd5 100644 --- a/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart +++ b/purchasely/example/integration_test/default_dismiss_handler_ios_test.dart @@ -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'; @@ -24,21 +26,15 @@ void main() { setUpAll(() async { debugPrint('SETUP → calling Purchasely.start()…'); - bool configured = false; - try { - configured = await Purchasely.apiKey(kApiKey) - .runningMode(PLYRunningMode.full) - .logLevel(PLYLogLevel.debug) - .allowDeeplink(true) - .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) + .allowDeeplink(true) + .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); }); @@ -48,9 +44,15 @@ void main() { (tester) async { await tester.runAsync(() async { PLYPresentationOutcome? globalOutcome; + var presentationViewed = false; await Purchasely.setDefaultPresentationDismissHandler((outcome) { globalOutcome = outcome; }); + Purchasely.listenToEvents((event) { + if (event.name == PLYEventName.PRESENTATION_VIEWED) { + presentationViewed = true; + } + }); // The SDK opens the presentation itself (deeplink) — its dismissal is // routed to the default handler, not to any per-request onDismissed. @@ -58,7 +60,16 @@ void main() { 'ply://ply/placements/$kPlacementAudiences'); expect(handled, isTrue, reason: 'deeplink route should be handled'); - // The concurrent driver taps ply_action_close once the paywall renders. + final viewedSw = Stopwatch()..start(); + while (!presentationViewed && + viewedSw.elapsed < const Duration(seconds: 30)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presentationViewed, isTrue, + reason: 'deeplink paywall should render before dismissal'); + debugPrint('DISMISS-DEFAULT-READY'); + + // The concurrent driver swipes once the readiness marker is logged. // Poll for the default handler to receive the dismissal outcome. final sw = Stopwatch()..start(); while ( @@ -79,6 +90,7 @@ void main() { debugPrint('default dismiss handler → ' 'closeReason=${globalOutcome!.closeReason} ' 'presentation=${globalOutcome!.presentation?.screenId}'); + Purchasely.stopListeningToEvents(); }); }); } diff --git a/purchasely/example/integration_test/default_dismiss_handler_test.dart b/purchasely/example/integration_test/default_dismiss_handler_test.dart index 4e0f5af6..a95d5a9e 100644 --- a/purchasely/example/integration_test/default_dismiss_handler_test.dart +++ b/purchasely/example/integration_test/default_dismiss_handler_test.dart @@ -14,6 +14,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'; @@ -21,11 +23,11 @@ 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) .allowDeeplink(true) - .stores([PLYStore.google]).start(); + .stores([PLYStore.google]).start()); expect(configured, isTrue); }); diff --git a/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart b/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart index 786c5727..9981081f 100644 --- a/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart +++ b/purchasely/example/integration_test/default_dismiss_via_display_ios_test.dart @@ -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'; @@ -24,21 +26,15 @@ void main() { setUpAll(() async { debugPrint('SETUP → calling Purchasely.start()…'); - bool configured = false; - try { - configured = await Purchasely.apiKey(kApiKey) - .runningMode(PLYRunningMode.full) - .logLevel(PLYLogLevel.debug) - .allowDeeplink(true) - .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) + .allowDeeplink(true) + .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); }); @@ -48,6 +44,7 @@ void main() { (tester) async { await tester.runAsync(() async { PLYPresentationOutcome? globalOutcome; + var presented = false; await Purchasely.setDefaultPresentationDismissHandler((outcome) { globalOutcome = outcome; }); @@ -58,13 +55,24 @@ void main() { // so the dismissal isn't handled locally and must reach the default handler. final presentation = await PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((presentation, error) { + if (presentation != null) presented = true; + }) .build() .preload(); // Fire-and-forget: intentionally not awaited. // ignore: unawaited_futures presentation.display(); - // The concurrent driver taps ply_action_close once the paywall renders. + final presentedSw = Stopwatch()..start(); + while (!presented && presentedSw.elapsed < const Duration(seconds: 30)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, + reason: 'fire-and-forget paywall should render before dismissal'); + debugPrint('DISMISS-DISPLAY-READY'); + + // The concurrent driver swipes once the readiness marker is logged. // Poll for the default handler to receive the dismissal outcome. final sw = Stopwatch()..start(); while ( diff --git a/purchasely/example/integration_test/default_dismiss_via_display_test.dart b/purchasely/example/integration_test/default_dismiss_via_display_test.dart index c8a006ae..0496a740 100644 --- a/purchasely/example/integration_test/default_dismiss_via_display_test.dart +++ b/purchasely/example/integration_test/default_dismiss_via_display_test.dart @@ -20,6 +20,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'; @@ -27,11 +29,11 @@ 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) .allowDeeplink(true) - .stores([PLYStore.google]).start(); + .stores([PLYStore.google]).start()); expect(configured, isTrue); }); diff --git a/purchasely/example/integration_test/flow_dismiss_ios_test.dart b/purchasely/example/integration_test/flow_dismiss_ios_test.dart new file mode 100644 index 00000000..f41b0110 --- /dev/null +++ b/purchasely/example/integration_test/flow_dismiss_ios_test.dart @@ -0,0 +1,196 @@ +// E2E: Flow display + dismiss coverage (S2) — iOS. Mirror of +// flow_dismiss_test.dart (Android). +// +// Exercises the `integration_test_flow` placement (flow id +// `integration_test_v_1`), reusing the SAME backend app/API key as the +// native Android `integration-tests` module (see +// `../Android/integration-tests/src/androidTest/java/com/purchasely/integration/FlowTests.kt` +// and `scenarios/FLOWS.md`, FLOW-01/FLOW-06). +// +// AXLabel discovery (idb ui describe-all --json, iPhone 17 Pro / iOS 26.5 — +// see task-4 report for the full transcript): the "calm" initial step's +// full accessibility tree is 11 StaticText elements, no button/image and NO +// close control at all: +// "What brings you to Calm?" (title) +// "We'll personalize recommendations based on your goals." (subtitle) +// "Reduce Stress" / "Better Sleep" / "Develop Gratitude" / "Reduce +// Anxiety" / "Increase Happiness" / "Improve Performance" / +// "Build Self Esteem" (the 7 option labels — all always rendered, no +// Android-style select/hide state visible in the AX tree) +// "Continue" (the validate control — unlike Android, always present, not +// hidden until a selection) +// This matches the Android finding (close_all is NOT on the calm screen +// either — every native FlowTests.kt scenario that taps close_all first +// navigates at least one step away from calm). So on iOS too, closing +// directly from "calm" has no UI control to drive — the programmatic +// fallback below is the CORRECT path here, not a workaround. +// +// tools/tap_label_ios.sh is still wired up (and usable by any suite that +// finds a real close/other control label at runtime), but this suite does +// not attempt one-step navigation past "calm": an exploratory coordinate +// tap on "Continue" (from the same AX dump) backgrounded the app instead of +// navigating — a distinct iOS-only flakiness on top of the Android +// close_all/purchase-CTA collision already discovered for that path (see +// task-4 report). Per the brief's "if unstable, leave it out and say so" +// clause, no nav is attempted here. +// +// Bridge gap check (do NOT invent fields): `PLYEventProperties` +// (lib/purchasely_flutter.dart:1146-1209) does NOT expose `flowId`, +// `flowSessionId`, `flowStepId` or `fromStepId` on either platform. What IS +// mapped to Dart is `PLYPresentation.flowId` (lib/src/presentation.dart:97, +// populated by the shared `presentationToMap` on both preload and outcome — +// iOS SwiftPurchaselyFlutterPlugin.swift:658/735), so this suite asserts +// flow identity via the PRESENTATION handle rather than via event +// properties. +// +// Run with: +// flutter test integration_test/flow_dismiss_ios_test.dart -d +// (no driver process needed — closing is via Purchasely.closeAllScreens(), +// documented above). + +import 'package:flutter/material.dart'; +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 kPlacementFlow = 'integration_test_flow'; +const String kFlowId = 'integration_test_v_1'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + 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'); + }); + + testWidgets('flow displays its initial step and dismisses via close', + (tester) async { + await tester.runAsync(() async { + final viewedScreens = []; + final closedEvents = []; + Purchasely.listenToEvents((event) { + switch (event.name) { + case PLYEventName.PRESENTATION_VIEWED: + viewedScreens.add(event.properties.displayed_presentation); + break; + case PLYEventName.PRESENTATION_CLOSED: + closedEvents.add(event); + break; + default: + break; + } + }); + + var presented = false; + Object? presentError; + // onPresented MUST be set on the BUILDER (see Task 2 report / + // re_display_ios_test.dart) — reassigning it on the handle after + // preload() is silently dropped for the first display cycle. + final request = + PLYPresentationBuilder.placement(kPlacementFlow).onPresented((p, e) { + presented = true; + presentError = e; + }).build(); + final presentation = await request.preload(); + + // Preload proof: real live screen, not deactivated/fallback. The flow + // may resolve to a dedicated type on native — log the REAL value + // rather than assuming `normal`. + debugPrint('preload → type=${presentation.type} ' + 'screenId=${presentation.screenId} ' + 'placementId=${presentation.placementId} ' + 'flowId=${presentation.flowId}'); + expect( + presentation.type, + isNot(anyOf( + PLYPresentationType.deactivated, PLYPresentationType.fallback)), + reason: 'integration_test_flow must resolve to a live screen, not a ' + 'deactivated/fallback placement, for this suite to be meaningful', + ); + expect(presentation.flowId, equals(kFlowId), + reason: 'PLYPresentation.flowId is the one flow identity field the ' + 'Dart bridge DOES map (unlike PLYEventProperties — see file ' + 'header)'); + + // --- Display: PRESENTATION_VIEWED for the "calm" initial step ------- + PLYPresentationOutcome? outcome; + Object? displayError; + // ignore: unawaited_futures + presentation.display().then((o) => outcome = o, + onError: (Object e, StackTrace st) => displayError = e); + + var sw = Stopwatch()..start(); + while (!presented && + displayError == null && + sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(displayError, isNull, + reason: 'display() must not error before presenting'); + expect(presentError, isNull, + reason: 'onPresented must not deliver an error'); + expect(presented, isTrue, reason: 'flow should present'); + + sw = Stopwatch()..start(); + while ( + viewedScreens.isEmpty && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(viewedScreens, isNotEmpty, + reason: 'PRESENTATION_VIEWED should fire for the initial step'); + expect(viewedScreens.first, equals('calm'), + reason: 'the flow\'s initial step is the "calm" selection screen ' + '(see native FlowTests.kt FLOW-01)'); + debugPrint('PRESENTATION_VIEWED screens so far: $viewedScreens'); + + // --- Close --------------------------------------------------------- + // The initial "calm" flow step has no close control in its AX tree and + // this suite intentionally has no host driver (documented above). Do + // not spend 40 seconds waiting for an interaction that cannot happen. + await Purchasely.closeAllScreens(); + sw = Stopwatch()..start(); + while (outcome == null && + displayError == null && + sw.elapsed < const Duration(seconds: 20)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(displayError, isNull, reason: 'display() must not error on close'); + expect(outcome, isNotNull, + reason: 'display() should resolve once the flow is closed'); + expect(outcome!.error, isNull); + expect( + outcome!.closeReason, + anyOf(PLYCloseReason.button, PLYCloseReason.backSystem, + PLYCloseReason.programmatic), + ); + debugPrint('outcome → closeReason=${outcome!.closeReason} ' + 'purchaseResult=${outcome!.purchaseResult}'); + + sw = Stopwatch()..start(); + while (closedEvents.isEmpty && sw.elapsed < const Duration(seconds: 10)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(closedEvents, isNotEmpty, + reason: 'PRESENTATION_CLOSED should be observed on the event ' + 'stream in addition to the display() outcome'); + debugPrint('PRESENTATION_CLOSED displayed_presentation=' + '${closedEvents.first.properties.displayed_presentation}'); + + Purchasely.stopListeningToEvents(); + }); + }); +} diff --git a/purchasely/example/integration_test/flow_dismiss_test.dart b/purchasely/example/integration_test/flow_dismiss_test.dart new file mode 100644 index 00000000..49f5f780 --- /dev/null +++ b/purchasely/example/integration_test/flow_dismiss_test.dart @@ -0,0 +1,244 @@ +// E2E: Flow display + dismiss coverage (S2) — Android. +// +// Exercises the `integration_test_flow` placement (flow id +// `integration_test_v_1`), reusing the SAME backend app/API key as the +// native Android `integration-tests` module (see +// `../Android/integration-tests/src/androidTest/java/com/purchasely/integration/FlowTests.kt` +// and `scenarios/FLOWS.md`, FLOW-01/FLOW-06): the flow's initial "calm" +// screen offers option items (content-desc `action:select_options`, each +// also containing its option label e.g. "Develop Gratitude"), a +// `action:validate_options` button (hidden until an option is selected), an +// `action:open_flow_step` button on intermediate screens, and a +// `action:close_all` button. +// +// Bridge gap check (do NOT invent fields): `PLYEventProperties` +// (lib/purchasely_flutter.dart:1146-1209) does NOT expose `flowId`, +// `flowSessionId`, `flowStepId` or `fromStepId` — the native events carry +// them (see FlowTests.kt's `event?.properties?.flowSessionId` etc.) but +// `transformToPLYEventProperties` (lib/purchasely_flutter.dart:783) never +// reads those wire keys into the Dart properties object. What IS mapped to +// Dart is `PLYPresentation.flowId` (lib/src/presentation.dart:97, populated +// by the shared `presentationToMap` on both preload and outcome — Android +// PurchaselyFlutterPlugin.kt:1521), so this suite asserts flow identity via +// the PRESENTATION handle rather than via event properties. +// +// Two equally-valid driver invocations (this test branches its CLOSE +// strategy on whether it observed the option/validate navigation — see +// "Close:" below): +// +// (A) Direct close from "calm" (no navigation) — taps action:close_all +// while still on the initial step: +// bash integration_test/tools/tap_content_desc.sh emulator-5554 "action:close_all" & +// flutter test integration_test/flow_dismiss_test.dart -d emulator-5554 +// +// (B) One-step navigation (select an option, validate), then a +// PROGRAMMATIC close (Purchasely.closeAllScreens(), not a UI tap — see +// why below): +// (bash integration_test/tools/tap_content_desc.sh emulator-5554 "Develop Gratitude" ; \ +// bash integration_test/tools/tap_content_desc.sh emulator-5554 "action:validate_options") & +// flutter test integration_test/flow_dismiss_test.dart -d emulator-5554 +// +// Why (B) does NOT also tap action:close_all: empirically (see task-4 +// report), the "Develop Gratitude" path's second step +// (pres_M0EIMZmLrH86aTzsXY89ttjEBKkPx) is itself a purchase-capable screen, +// and a naive content-desc substring search for "action:close_all" landed on +// its PURCHASE control instead of a dedicated close button (a real tap, +// e.g. attempting to buy — harmless here since there's no Play Billing on +// the emulator, but it never dismisses the flow, so display() never +// resolves). Closing programmatically once the second step is confirmed +// avoids that ambiguity entirely and is still a real proof of the dismiss +// contract post-navigation. + +import 'package:flutter/material.dart'; +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 kPlacementFlow = 'integration_test_flow'; +const String kFlowId = 'integration_test_v_1'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .stores([PLYStore.google]).start()); + expect(configured, isTrue, + reason: 'SDK should configure against the real backend'); + }); + + testWidgets('flow displays its initial step and dismisses via close_all', + (tester) async { + await tester.runAsync(() async { + final viewedScreens = []; + final optionsSelected = []; + final optionsValidated = []; + final closedEvents = []; + Purchasely.listenToEvents((event) { + switch (event.name) { + case PLYEventName.PRESENTATION_VIEWED: + viewedScreens.add(event.properties.displayed_presentation); + break; + case PLYEventName.OPTIONS_SELECTED: + optionsSelected.add(event); + break; + case PLYEventName.OPTIONS_VALIDATED: + optionsValidated.add(event); + break; + case PLYEventName.PRESENTATION_CLOSED: + closedEvents.add(event); + break; + default: + break; + } + }); + + var presented = false; + // onPresented MUST be set on the BUILDER (see Task 2 report / + // re_display_test.dart) — reassigning it on the handle after + // preload() is silently dropped for the first display cycle. + final request = PLYPresentationBuilder.placement(kPlacementFlow) + .onPresented((p, e) => presented = true) + .build(); + final presentation = await request.preload(); + + // Preload proof: real live screen, not deactivated/fallback. The flow + // may resolve to a dedicated type on native — log the REAL value + // rather than assuming `normal`. + debugPrint('preload → type=${presentation.type} ' + 'screenId=${presentation.screenId} ' + 'placementId=${presentation.placementId} ' + 'flowId=${presentation.flowId}'); + expect( + presentation.type, + isNot(anyOf( + PLYPresentationType.deactivated, PLYPresentationType.fallback)), + reason: 'integration_test_flow must resolve to a live screen, not a ' + 'deactivated/fallback placement, for this suite to be meaningful', + ); + expect(presentation.flowId, equals(kFlowId), + reason: 'PLYPresentation.flowId is the one flow identity field the ' + 'Dart bridge DOES map (unlike PLYEventProperties — see file ' + 'header)'); + + // --- Display: PRESENTATION_VIEWED for the "calm" initial step ------- + PLYPresentationOutcome? outcome; + // ignore: unawaited_futures + presentation.display().then((o) => outcome = o); + + var sw = Stopwatch()..start(); + while (!presented && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'flow should present'); + + sw = Stopwatch()..start(); + while ( + viewedScreens.isEmpty && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(viewedScreens, isNotEmpty, + reason: 'PRESENTATION_VIEWED should fire for the initial step'); + expect(viewedScreens.first, equals('calm'), + reason: 'the flow\'s initial step is the "calm" selection screen ' + '(see native FlowTests.kt FLOW-01)'); + debugPrint('PRESENTATION_VIEWED screens so far: $viewedScreens'); + + // --- Optional one-step navigation: select an option, validate ------- + // The driver (if chained per pattern B above) taps an option then + // action:validate_options. Wait a bit to see whether it happened; if + // it did, assert the events fired and a second screen was viewed. If + // not, this is simply not exercised — no invented pass, no skip. + sw = Stopwatch()..start(); + while (optionsValidated.isEmpty && + sw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + final navigated = optionsValidated.isNotEmpty; + if (navigated) { + expect(optionsSelected, isNotEmpty, + reason: 'OPTIONS_SELECTED should precede OPTIONS_VALIDATED'); + sw = Stopwatch()..start(); + while (viewedScreens.length < 2 && + sw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(viewedScreens.length, greaterThanOrEqualTo(2), + reason: 'validating an option should navigate to a second step'); + debugPrint('one-step navigation observed → viewedScreens: ' + '$viewedScreens'); + } else { + debugPrint('one-step navigation NOT observed within 15s — driver ' + 'was likely invoked with pattern (A) (close_all only, no ' + 'option/validate taps); proceeding to a direct close_all-based ' + 'close from "calm".'); + } + + // --- Close -------------------------------------------------------- + if (navigated) { + // See file header: after navigating past "calm", a content-desc + // substring search for "action:close_all" is NOT safe — it can + // match a purchase control's compound action descriptor on some + // steps instead of a real close button. Close programmatically + // rather than risk that tap. + debugPrint('navigated past "calm" — closing via ' + 'Purchasely.closeAllScreens() (programmatic) rather than ' + 'hunting for a close_all UI control on the post-navigation ' + 'screen (see file header for why)'); + await Purchasely.closeAllScreens(); + sw = Stopwatch()..start(); + while (outcome == null && sw.elapsed < const Duration(seconds: 20)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } else { + // Still on "calm": safe to wait for the driver's action:close_all + // tap (pattern A above). + sw = Stopwatch()..start(); + while (outcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + if (outcome == null) { + // Fallback: driver couldn't find/tap the close_all button. Close + // programmatically so the suite still proves the dismiss + // contract, with an honest note in the log (NOT a silently + // invented pass). + debugPrint('close_all button not observed closing the flow ' + 'within 40s — falling back to Purchasely.closeAllScreens() ' + '(programmatic; driver tap was not confirmed)'); + await Purchasely.closeAllScreens(); + sw = Stopwatch()..start(); + while (outcome == null && sw.elapsed < const Duration(seconds: 20)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + } + expect(outcome, isNotNull, + reason: 'display() should resolve once the flow is closed'); + expect(outcome!.error, isNull); + expect( + outcome!.closeReason, + anyOf(PLYCloseReason.button, PLYCloseReason.backSystem, + PLYCloseReason.programmatic), + ); + debugPrint('outcome → closeReason=${outcome!.closeReason} ' + 'purchaseResult=${outcome!.purchaseResult}'); + + sw = Stopwatch()..start(); + while (closedEvents.isEmpty && sw.elapsed < const Duration(seconds: 10)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(closedEvents, isNotEmpty, + reason: 'PRESENTATION_CLOSED should be observed on the event ' + 'stream in addition to the display() outcome'); + debugPrint('PRESENTATION_CLOSED displayed_presentation=' + '${closedEvents.first.properties.displayed_presentation}'); + + Purchasely.stopListeningToEvents(); + }); + }); +} diff --git a/purchasely/example/integration_test/helpers/e2e_start.dart b/purchasely/example/integration_test/helpers/e2e_start.dart new file mode 100644 index 00000000..f464903f --- /dev/null +++ b/purchasely/example/integration_test/helpers/e2e_start.dart @@ -0,0 +1,99 @@ +// Shared "start the SDK with retry" helper for integration_test/ suites. +// +// CI runners occasionally hit a transient network/TLS hiccup during +// Purchasely.start() (observed: "A TLS error caused the secure connection to +// fail", start() failing 3/3 on a cold-start deeplink run) that has nothing to +// do with the SDK or the suite's own setup. Retrying blindly would mask real +// regressions, so only failures that LOOK like network/TLS errors get +// retried — everything else rethrows immediately. +// +// Each suite keeps its own `Purchasely.apiKey(...)....start()` chain (with +// whatever storekitVersion / allowDeeplink / stores / per-attempt +// `.timeout(...)` it already had); wrap that chain in a closure and pass it +// here instead of awaiting it directly: +// +// setUpAll(() async { +// 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'))); +// expect(configured, isTrue); +// }); + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +/// Total attempts: the initial call + 2 retries. +const int _kMaxAttempts = 3; + +/// Backoff before retry N — index 0 is the delay before attempt 2, index 1 +/// before attempt 3. +const List _kBackoff = [Duration(seconds: 2), Duration(seconds: 4)]; + +/// Case-insensitive substrings that mark an error as network/TLS-ish. +const List _kNetworkNeedles = [ + 'tls', + 'ssl', + 'handshake', + 'socket', + 'network', + 'connection', + 'timed out', +]; + +/// Runs the caller-provided `Purchasely.start()` chain [start], retrying up +/// to [_kMaxAttempts] times with a (2s, 4s) backoff — but ONLY when the +/// failure looks like a transient network/TLS error. Any other error +/// rethrows immediately, on the first attempt. +Future startWithRetry(Future Function() start) async { + for (var attempt = 1; attempt <= _kMaxAttempts; attempt++) { + try { + // Backstop timeout (Greptile P2, PR #138 review): some callers (e.g. + // flow_dismiss_test.dart, dart_android_bridge_test.dart) don't chain + // their own `.timeout(...)` on the start() call, unlike the iOS + // suites that do — without this, a hung start() is only bounded by + // the ~600s CI watchdog. A caller's own tighter `.timeout(...)` still + // wins (it fires first). Deliberately left as the *default* + // TimeoutException (no onTimeout override): its message doesn't + // contain "timed out" or any other _kNetworkNeedles substring, so + // _networkMotif treats it as a real failure and rethrows immediately + // instead of silently retrying it like a network hiccup — a genuine + // hang should surface as a failure, not get masked by the backoff. + return await start().timeout(const Duration(seconds: 180)); + } catch (e) { + final motif = _networkMotif(e); + if (motif == null || attempt == _kMaxAttempts) rethrow; + final delay = _kBackoff[attempt - 1]; + // ignore: avoid_print + print('startWithRetry: attempt $attempt/$_kMaxAttempts failed with a ' + 'network-ish error (motif="$motif"): $e — retrying in ' + '${delay.inSeconds}s…'); + await Future.delayed(delay); + } + } + // Unreachable: the loop above always returns or rethrows. + throw StateError('startWithRetry: exhausted attempts without a result'); +} + +/// Returns the matched keyword if [error] looks like a transient +/// network/TLS failure, or `null` if it should fail the suite immediately. +String? _networkMotif(Object error) { + final message = _messageOf(error).toLowerCase(); + for (final needle in _kNetworkNeedles) { + if (message.contains(needle)) return needle; + } + return null; +} + +String _messageOf(Object error) { + if (error is SocketException) return error.message; + if (error is PlatformException) return error.message ?? error.toString(); + if (error is TimeoutException) return error.message ?? error.toString(); + return error.toString(); +} diff --git a/purchasely/example/integration_test/inline_events_test.dart b/purchasely/example/integration_test/inline_events_test.dart index d27e8219..149037a3 100644 --- a/purchasely/example/integration_test/inline_events_test.dart +++ b/purchasely/example/integration_test/inline_events_test.dart @@ -31,6 +31,8 @@ import 'package:integration_test/integration_test.dart'; import 'package:purchasely_flutter/native_view_widget.dart'; import 'package:purchasely_flutter/purchasely_flutter.dart'; +import 'helpers/e2e_start.dart'; + const String kApiKey = String.fromEnvironment('PLY_KEY', defaultValue: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d'); const String kPlacement = @@ -40,11 +42,11 @@ 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) .allowDeeplink(true) - .stores([PLYStore.google]).start(); + .stores([PLYStore.google]).start()); expect(configured, isTrue); }); @@ -109,6 +111,8 @@ void main() { debugPrint('inline event flow OK → ${globalPaywallEvent!.name}'); Purchasely.stopListeningToEvents(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); }); }); } diff --git a/purchasely/example/integration_test/inline_paywall_test.dart b/purchasely/example/integration_test/inline_paywall_test.dart index 2240f84b..90ef1728 100644 --- a/purchasely/example/integration_test/inline_paywall_test.dart +++ b/purchasely/example/integration_test/inline_paywall_test.dart @@ -24,6 +24,8 @@ import 'package:integration_test/integration_test.dart'; import 'package:purchasely_flutter/native_view_widget.dart'; import 'package:purchasely_flutter/purchasely_flutter.dart'; +import 'helpers/e2e_start.dart'; + const String kApiKey = String.fromEnvironment('PLY_KEY', defaultValue: 'fcb39be4-2ba4-4db7-bde3-2a5a1e20745d'); const String kPlacement = @@ -33,11 +35,11 @@ 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) .allowDeeplink(true) - .stores([PLYStore.google]).start(); + .stores([PLYStore.google]).start()); expect(configured, isTrue); }); @@ -81,6 +83,9 @@ void main() { expect(presentation.screenId, isNotNull); debugPrint('inline rendered screenId=${presentation.screenId}'); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); }); }); } diff --git a/purchasely/example/integration_test/interceptor_actions_ios_test.dart b/purchasely/example/integration_test/interceptor_actions_ios_test.dart new file mode 100644 index 00000000..0c668796 --- /dev/null +++ b/purchasely/example/integration_test/interceptor_actions_ios_test.dart @@ -0,0 +1,385 @@ +// E2E: iOS action interceptor "failed" / "notHandled" completions on a real +// tap — S5/S6 in the Go/No-Go audit. +// +// DISCOVERY #1 (runtime, see task-5-report.md for the full trail): the +// integration_test_audiences PLACEMENT only serves its "Login"/"Restore" +// Navigate screen ("DailyMail+ Pill Landing") to an audience gated on +// `store_name == GOOGLE_PLAY_STORE` — i.e. Android only (confirmed via the +// purchasely admin MCP's get_placement/get_screen on +// app_hIOwu3kgCVv5fupBjZSDcEbx1ohYb, "Test run Purchasely"). On iOS the +// placement always falls through to its default_presentable +// ("072524_exp_magic_cut"), which has no Login button at all — confirmed by +// an `idb ui describe-all` dump against a live run of the existing, CI-green +// interceptor_trigger_ios_test.dart (same placement): AXLabels were exactly +// Continue / Privacy Policy / Terms of Use / Restore purchase / Powered by +// Purchasely, no "Login". So this suite loads "DailyMail+ Pill Landing" +// DIRECTLY via `PLYPresentationBuilder.screen(id)` (a first-class, +// already-precedented SDK entry point — see dart_ios_bridge_test.dart's +// "PLYPresentationBuilder.screen(id) fonctionne" test) instead of +// `.placement(kPlacementAudiences)`. This is the exact same screen content +// Android's INTERCEPTOR.md scenarios (ACT-03..06/09) exercise: a "Login" text +// label wired to `action.type: "deeplink"`, `value: "https://show_login"` — +// surfaced to Dart as `PLYPresentationActionKind.navigate`, NOT the built-in +// `login` kind. NOTE: `.screen(id)` takes the screen's VENDOR_ID +// (`pres_Yzzy4U8bkPAzByL0QS8KJDj6mBWKd6a`), not its admin/console public_id +// (`pres_MnZEWiJ2VDy80A3JwWqsWeh2pKQgQXQ`) — passing the public_id silently +// loads an unrelated bundled screen instead of erroring. +// +// DISCOVERY #2 (runtime): `PLYEventName.LINK_OPENED` fires on EVERY real tap +// of a navigate action regardless of what the interceptor resolves with +// (`success`, `failed`, and `notHandled` all fired it in isolated probes) — +// it is an "action was triggered" analytics event, NOT a signal that the SDK +// actually performed its default open-link handling. So it is NOT usable to +// distinguish S5 (failed) from S6 (notHandled). +// +// The signal that DOES distinguish them, observed directly: resolving +// `notHandled` makes the SDK actually open `https://show_login` — the +// simulator hands off to Safari, which backgrounds the Flutter app. That is +// visible purely in Dart via `WidgetsBindingObserver.didChangeAppLifecycleState` +// (`AppLifecycleState.inactive` → `.hidden` → `.paused`), with NO such +// transition for `failed` (the paywall stays foregrounded and interactive). +// This is still proof via a Dart-in-memory callback, never a screenshot. +// +// CAUTION (see task-5-report.md): once iOS backgrounds the app for real, the +// Flutter engine's own isolate is suspended by the OS — any further +// `await`'d native round-trip (event channel, method channel) in THIS test +// process can hang indefinitely until something brings the app back to the +// foreground. So S6 does NOT attempt a programmatic `presentation.close()` +// after observing the backgrounding — it asserts and returns. Bringing the +// app back to the foreground (so `tearDown`'s interceptor cleanup can +// complete, and Safari doesn't bleed into the next suite) is a host-side +// step run alongside the tap driver, e.g.: +// xcrun simctl launch com.purchasely.demo +// +// Both tests share one real tap on "Login" after a Dart readiness marker. +// The host driver scales the control coordinates to the active simulator and +// the tests diverge only in what the interceptor resolves with: +// A. PLYInterceptResult.failed — the SDK must NOT fall back to its own +// default "open the link" handling; the paywall stays up. +// B. PLYInterceptResult.notHandled — the SDK proceeds with its own default +// handling of the Navigate action, backgrounding the app to Safari. +// +// Run together with the driver — one tap per test, chained (each test opens +// its own paywall instance) — and a re-foreground step after the second tap: +// (SUITE_LOG=/tmp/interceptors.log \ +// bash .../interceptor_actions_driver_ios.sh ) & +// flutter test integration_test/interceptor_actions_ios_test.dart -d + +import 'package:flutter/material.dart'; +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'; + +// The "DailyMail+ Pill Landing" screen's VENDOR_ID — see DISCOVERY #1 above. +const String kLoginRestoreScreenId = 'pres_Yzzy4U8bkPAzByL0QS8KJDj6mBWKd6a'; + +// AXLabel discovered at runtime via `idb ui describe-all --json` against the +// "DailyMail+ Pill Landing" screen (get_screen: component_label text +// "en": "Login") — the visible button text is exactly "Login", matching the +// Navigate button documented in global-constraints.md (url +// https://show_login). +const String kLoginLabel = 'Login'; + +/// Records `AppLifecycleState` transitions into a shared log — the Dart-side +/// evidence that the SDK's default Navigate handling actually backgrounded +/// the app to open the link (see DISCOVERY #2 above). +class _LifecycleRecorder extends WidgetsBindingObserver { + final void Function(AppLifecycleState) onChange; + _LifecycleRecorder(this.onChange); + @override + void didChangeAppLifecycleState(AppLifecycleState state) => onChange(state); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + 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); + }); + + tearDown(() async { + await Purchasely.removeAllActionInterceptors(); + Purchasely.stopListeningToEvents(); + }); + + testWidgets( + 'S5: navigate interceptor resolved as failed — SDK must not open the link', + (tester) async { + await tester.runAsync(() async { + final callbackOrder = []; + PLYInterceptorInfo? capturedInfo; + PLYActionPayload? capturedPayload; + var presented = false; + var dismissed = false; + + final lifecycle = + _LifecycleRecorder((state) => callbackOrder.add('lifecycle:$state')); + WidgetsBinding.instance.addObserver(lifecycle); + + Purchasely.listenToEvents((event) { + callbackOrder.add('event:${event.name}'); + }); + + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) async { + callbackOrder.add('interceptor:triggered'); + capturedInfo = info; + capturedPayload = payload; + callbackOrder.add('interceptor:resolved(failed)'); + return PLYInterceptResult.failed; + }, + ); + + final request = PLYPresentationBuilder.screen(kLoginRestoreScreenId) + .onPresented((p, e) { + if (p != null) { + presented = true; + callbackOrder.add('presented'); + } + }).onDismissed((o) { + dismissed = true; + callbackOrder.add('dismissed(${o.closeReason})'); + }).build(); + final presentation = await request.preload(); + + // Load-proof: fail loud and clear if the SDK silently served its + // bundled fallback instead of "DailyMail+ Pill Landing" (e.g. the + // screen was renamed/deactivated) — otherwise S5 dies 40s+90s later as + // an illegible driver timeout instead of a crisp assertion failure. + expect(presentation.screenId, isNotNull); + expect( + presentation.type, + isNot(anyOf( + PLYPresentationType.fallback, PLYPresentationType.deactivated)), + reason: 'screen $kLoginRestoreScreenId not served — renamed/' + 'deactivated? (S5/S6 would otherwise die as an illegible driver ' + 'timeout)', + ); + + PLYPresentationOutcome? outcome; + // Fire-and-forget: the whole point of test A is that this stays pending + // (the paywall must remain displayed) until the programmatic close below. + // ignore: unawaited_futures + presentation + .display(const PLYTransition.fullScreen()) + .then((o) => outcome = o); + + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present'); + debugPrint('INTERCEPTOR-S5-READY'); + + // The concurrent driver taps "Login" (a Navigate action, not the + // built-in `login` kind). Poll for the interceptor to fire. + final fireSw = Stopwatch()..start(); + while (capturedPayload == null && + fireSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(capturedPayload, isNotNull, + reason: 'navigate interceptor should fire on a real tap on ' + '"$kLoginLabel" — driver: tools/tap_after_marker_ios.sh'); + expect(capturedPayload, isA(), + reason: 'the "Login" button is a Navigate action, not built-in ' + 'login'); + final navigate = capturedPayload! as PLYNavigatePayload; + expect(navigate.kind, PLYPresentationActionKind.navigate); + // PR #136 enriched the iOS interceptor payload for navigate actions — + // url (and title, when the native screen carries one) must now be + // present on the typed payload. + expect(navigate.url, 'https://show_login'); + expect(capturedInfo, isNotNull); + debugPrint('[S5/failed] captured payload → url=${navigate.url} ' + 'title=${navigate.title} contentId=${capturedInfo!.contentId}'); + + // Give the SDK a window to (not) act on the "failed" resolution before + // asserting its absence — the app must stay foregrounded throughout. + await Future.delayed(const Duration(seconds: 8)); + + expect( + callbackOrder.any((e) => e.startsWith('lifecycle:')), + isFalse, + reason: 'PLYInterceptResult.failed must suppress the SDK\'s default ' + '"open link" handling — the app must never background (no ' + 'AppLifecycleState transition), unlike S6/notHandled', + ); + expect(dismissed, isFalse, + reason: 'a failed navigate action must leave the paywall displayed ' + '(no dismissal)'); + expect(outcome, isNull, + reason: 'display() must still be pending — the paywall stayed up'); + + // Clean, programmatic close (siblings show both close_paywall_ios.sh + // and programmatic close; this test already holds a presentation + // handle, so programmatic close is the simplest option here). Safe + // here — unlike S6, the app never backgrounded, so the native + // round-trip can't hang. + await presentation.close(); + final closeSw = Stopwatch()..start(); + while (outcome == null && closeSw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(outcome, isNotNull, + reason: 'presentation.close() should resolve display()'); + + debugPrint('[S5/failed] callback order: $callbackOrder'); + expect( + callbackOrder.indexOf('interceptor:triggered') < + callbackOrder.indexOf('interceptor:resolved(failed)'), + isTrue, + reason: 'the interceptor must be triggered before it resolves', + ); + + WidgetsBinding.instance.removeObserver(lifecycle); + }); + }); + + testWidgets( + 'S6: navigate interceptor resolved as notHandled — SDK opens the link', + (tester) async { + await tester.runAsync(() async { + final callbackOrder = []; + PLYInterceptorInfo? capturedInfo; + PLYActionPayload? capturedPayload; + var presented = false; + var backgrounded = false; + + final lifecycle = _LifecycleRecorder((state) { + callbackOrder.add('lifecycle:$state'); + if (state == AppLifecycleState.paused || + state == AppLifecycleState.hidden || + state == AppLifecycleState.inactive) { + backgrounded = true; + } + }); + WidgetsBinding.instance.addObserver(lifecycle); + + Purchasely.listenToEvents((event) { + callbackOrder.add('event:${event.name}'); + }); + + await Purchasely.interceptAction( + PLYPresentationActionKind.navigate, + (info, payload) async { + callbackOrder.add('interceptor:triggered'); + capturedInfo = info; + capturedPayload = payload; + callbackOrder.add('interceptor:resolved(notHandled)'); + return PLYInterceptResult.notHandled; + }, + ); + + final request = PLYPresentationBuilder.screen(kLoginRestoreScreenId) + .onPresented((p, e) { + if (p != null) { + presented = true; + callbackOrder.add('presented'); + } + }).build(); + final presentation = await request.preload(); + + // Load-proof: fail loud and clear if the SDK silently served its + // bundled fallback instead of "DailyMail+ Pill Landing" (e.g. the + // screen was renamed/deactivated) — otherwise S6 dies 40s+90s later as + // an illegible driver timeout instead of a crisp assertion failure. + expect(presentation.screenId, isNotNull); + expect( + presentation.type, + isNot(anyOf( + PLYPresentationType.fallback, PLYPresentationType.deactivated)), + reason: 'screen $kLoginRestoreScreenId not served — renamed/' + 'deactivated? (S5/S6 would otherwise die as an illegible driver ' + 'timeout)', + ); + + // Fire-and-forget: NOT awaited, and no result is ever read. Once the + // SDK backgrounds the app (see CAUTION above), this isolate may be + // suspended before display()'s own future would resolve — this test + // only needs the interceptor + lifecycle evidence, not the dismiss + // outcome, so it deliberately never blocks on it. + // ignore: unawaited_futures, unused_local_variable + presentation.display(const PLYTransition.fullScreen()); + + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present'); + debugPrint('INTERCEPTOR-S6-READY'); + + // The concurrent driver taps "Login" again (this test's own paywall + // instance). Poll for the interceptor to fire. + final fireSw = Stopwatch()..start(); + while (capturedPayload == null && + fireSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + expect(capturedPayload, isNotNull, + reason: 'navigate interceptor should fire on a real tap on ' + '"$kLoginLabel" — driver: tools/tap_after_marker_ios.sh'); + expect(capturedPayload, isA()); + final navigate = capturedPayload! as PLYNavigatePayload; + expect(navigate.kind, PLYPresentationActionKind.navigate); + expect(navigate.url, 'https://show_login'); + expect(capturedInfo, isNotNull); + debugPrint('[S6/notHandled] captured payload → url=${navigate.url} ' + 'title=${navigate.title} contentId=${capturedInfo!.contentId}'); + + // notHandled → the SDK proceeds with its own default handling of the + // Navigate action: it opens the link, backgrounding the app to Safari. + // Poll briefly for the AppLifecycleState transition — once it fires, + // this isolate may be suspended by iOS at any moment (see the CAUTION + // note above), so nothing further is attempted after this. + final bgSw = Stopwatch()..start(); + while (!backgrounded && bgSw.elapsed < const Duration(seconds: 10)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + + debugPrint('[S6/notHandled] callback order: $callbackOrder'); + expect(backgrounded, isTrue, + reason: 'PLYInterceptResult.notHandled must let the SDK fall back ' + 'to its default "open link" handling — the app should ' + 'background (AppLifecycleState transition) as it hands off to ' + 'Safari, unlike S5/failed where it never does'); + expect( + callbackOrder.indexOf('interceptor:triggered') < + callbackOrder.indexOf('interceptor:resolved(notHandled)'), + isTrue, + reason: 'the interceptor must be triggered before it resolves', + ); + final resolvedIdx = + callbackOrder.indexOf('interceptor:resolved(notHandled)'); + final lifecycleIdx = + callbackOrder.indexWhere((e) => e.startsWith('lifecycle:')); + expect(resolvedIdx < lifecycleIdx, isTrue, + reason: 'the app must only background AFTER the interceptor ' + 'resolves notHandled — distinct from S5, where it never ' + 'backgrounds at all'); + + // No presentation.close() here — see the CAUTION note at the top of + // this file. Bringing the app back to the foreground (so tearDown's + // native calls don't hang, and Safari doesn't bleed into the next + // suite) is a host-side step run alongside the driver. + WidgetsBinding.instance.removeObserver(lifecycle); + }); + }); +} diff --git a/purchasely/example/integration_test/interceptor_trigger_ios_test.dart b/purchasely/example/integration_test/interceptor_trigger_ios_test.dart index 9fb28e6a..a2353ede 100644 --- a/purchasely/example/integration_test/interceptor_trigger_ios_test.dart +++ b/purchasely/example/integration_test/interceptor_trigger_ios_test.dart @@ -1,13 +1,12 @@ // E2E: action interceptor is actually TRIGGERED by a real tap on the native // paywall, and the typed payload is delivered to Dart. // -// Mirror of interceptor_trigger_test.dart for iOS. Uses PLYStore.apple and -// a concurrent host-side driver (tools/tap_purchase_ios.sh) that uses idb to -// tap the purchase button by its accessibility identifier -// (ply_action_purchase_). +// Mirror of interceptor_trigger_test.dart for iOS. A concurrent host-side +// driver waits for the Dart readiness marker before tapping the purchase CTA. // // Run together with the driver: -// (bash .../tap_purchase_ios.sh &) ; \ +// (SUITE_LOG=/tmp/interceptor.log \ +// bash .../purchase_interceptor_driver_ios.sh &) ; \ // flutter test integration_test/interceptor_trigger_ios_test.dart -d import 'package:flutter/material.dart'; @@ -15,6 +14,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'; @@ -23,20 +24,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); }); @@ -77,8 +72,10 @@ void main() { await Future.delayed(const Duration(milliseconds: 250)); } expect(presented, isTrue, reason: 'paywall should present'); + debugPrint('INTERCEPTOR-PURCHASE-READY'); - // The concurrent driver taps the purchase button. Poll for interceptor. + // The concurrent driver waits for the readiness marker, then taps the + // purchase button. Poll for the interceptor callback. final fireSw = Stopwatch()..start(); while (capturedPayload == null && fireSw.elapsed < const Duration(seconds: 40)) { @@ -98,6 +95,8 @@ void main() { 'contentId=${capturedInfo!.contentId}'); await Purchasely.removeAllActionInterceptors(); + await Purchasely.closeAllScreens(); + await Future.delayed(const Duration(seconds: 1)); }); }); } diff --git a/purchasely/example/integration_test/interceptor_trigger_test.dart b/purchasely/example/integration_test/interceptor_trigger_test.dart index 396c1764..d3945c51 100644 --- a/purchasely/example/integration_test/interceptor_trigger_test.dart +++ b/purchasely/example/integration_test/interceptor_trigger_test.dart @@ -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'; @@ -23,10 +25,10 @@ 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); }); diff --git a/purchasely/example/integration_test/ios_core_batch_test.dart b/purchasely/example/integration_test/ios_core_batch_test.dart new file mode 100644 index 00000000..b4c57b89 --- /dev/null +++ b/purchasely/example/integration_test/ios_core_batch_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'dart_ios_bridge_test.dart' as bridge; +import 'deeplink_cold_start_test.dart' as deeplink; +import 'flow_dismiss_ios_test.dart' as flow_dismiss; +import 'user_attribute_listener_test.dart' as user_attributes; + +/// Runs compatible driver-free iOS scenarios in a single app installation. +void main() { + // This scenario owns the first SDK start because it supplies a launch-time + // deeplink. All following scenarios use the same Purchasely project. + group('cold-start deeplink', deeplink.main); + group('flow dismiss', flow_dismiss.main); + group('Dart iOS bridge', bridge.main); + group('user-attribute listener', user_attributes.main); +} diff --git a/purchasely/example/integration_test/ios_dismiss_batch_test.dart b/purchasely/example/integration_test/ios_dismiss_batch_test.dart new file mode 100644 index 00000000..fc6a9c6f --- /dev/null +++ b/purchasely/example/integration_test/ios_dismiss_batch_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'default_dismiss_handler_ios_test.dart' as default_dismiss; +import 'default_dismiss_via_display_ios_test.dart' as display_dismiss; +import 'local_dismiss_handler_ios_test.dart' as local_dismiss; + +/// Runs the three dismiss scenarios in a single app installation. +void main() { + group('default dismiss handler', default_dismiss.main); + group('default dismiss via display', display_dismiss.main); + group('local dismiss handler', local_dismiss.main); +} diff --git a/purchasely/example/integration_test/ios_inline_batch_test.dart b/purchasely/example/integration_test/ios_inline_batch_test.dart new file mode 100644 index 00000000..46d4aa02 --- /dev/null +++ b/purchasely/example/integration_test/ios_inline_batch_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'inline_events_test.dart' as inline_events; +import 'inline_paywall_test.dart' as inline_paywall; + +/// Runs inline scenarios sharing the same Purchasely project and installation. +void main() { + group('inline event stream', inline_events.main); + group('inline paywall', inline_paywall.main); +} diff --git a/purchasely/example/integration_test/ios_transition_batch_test.dart b/purchasely/example/integration_test/ios_transition_batch_test.dart new file mode 100644 index 00000000..8a02558a --- /dev/null +++ b/purchasely/example/integration_test/ios_transition_batch_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'modal_dismissible_ios_test.dart' as modal_dismissible; +import 're_display_ios_test.dart' as re_display; + +/// Runs the transition regressions in a single app installation. +void main() { + group('modal dismissible transition', modal_dismissible.main); + group('re-display transition', re_display.main); +} diff --git a/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart b/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart index 899aef10..e9e4e1e9 100644 --- a/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart +++ b/purchasely/example/integration_test/local_dismiss_handler_ios_test.dart @@ -17,6 +17,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'; @@ -25,21 +27,15 @@ void main() { setUpAll(() async { debugPrint('SETUP → calling Purchasely.start()…'); - bool configured = false; - try { - configured = await Purchasely.apiKey(kApiKey) - .runningMode(PLYRunningMode.full) - .logLevel(PLYLogLevel.debug) - .allowDeeplink(true) - .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) + .allowDeeplink(true) + .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); }); @@ -60,11 +56,14 @@ void main() { // awaits display(): both local channels must receive the outcome and the // default handler must NOT fire. final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((presentation, error) { + if (presentation != null) debugPrint('DISMISS-LOCAL-READY'); + }) .onDismissed((outcome) => localOutcome = outcome) .build(); await request.preload(); - // The concurrent driver taps ply_action_close once the paywall renders, + // The concurrent driver waits for the readiness marker and swipes, // which resolves the awaited display() future. final outcome = await request.display().timeout( const Duration(seconds: 50), diff --git a/purchasely/example/integration_test/local_dismiss_handler_test.dart b/purchasely/example/integration_test/local_dismiss_handler_test.dart index 7b12c0b7..f75a78a4 100644 --- a/purchasely/example/integration_test/local_dismiss_handler_test.dart +++ b/purchasely/example/integration_test/local_dismiss_handler_test.dart @@ -19,6 +19,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'; @@ -26,11 +28,11 @@ 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) .allowDeeplink(true) - .stores([PLYStore.google]).start(); + .stores([PLYStore.google]).start()); expect(configured, isTrue); }); diff --git a/purchasely/example/integration_test/modal_dismissible_ios_test.dart b/purchasely/example/integration_test/modal_dismissible_ios_test.dart new file mode 100644 index 00000000..e0eee09a --- /dev/null +++ b/purchasely/example/integration_test/modal_dismissible_ios_test.dart @@ -0,0 +1,215 @@ +// E2E: iOS `PLYTransition.modal(dismissible:)` regression guard (PR #136 — +// "modal dismissible iOS ignoré") PLUS the missing interactive-swipe-dismiss +// coverage. +// +// Before the fix, `SwiftPurchaselyFlutterPlugin.parseTransition` returned the +// static `.modal` case and dropped the Dart-side `dismissible` flag, so a +// modal declared non-dismissible was still swipe-dismissible on iOS. This +// suite has two tests: +// 1. `dismissible: false` — an interactive swipe-down must be a no-op: +// display() stays pending and no PRESENTATION_CLOSED fires. Only a +// programmatic `presentation.close()` may dismiss it. +// 2. `dismissible: true` (the default) — an interactive swipe-down MUST +// resolve display() with a dismissed outcome. This is new coverage: no +// existing suite drove an interactive swipe against a *dismissible* +// modal and asserted on the resulting outcome. +// +// Uses PLYStore.apple, host-opened via `preload()` + fire-and-forget +// `display()` (pattern of default_dismiss_via_display_ios_test.dart). A +// concurrent host-side driver (tools/swipe_dismiss_ios.sh) uses idb to send +// interactive swipe-down gestures once the paywall renders — it does NOT +// assert the outcome itself, that's this Dart suite's job. +// +// Run together with the driver — one invocation per test (the file has two +// separate display() cycles), chained so the second waits for the first to +// finish: +// (bash .../swipe_dismiss_ios.sh 2 ; \ +// bash .../swipe_dismiss_ios.sh 2) & +// flutter test integration_test/modal_dismissible_ios_test.dart -d + +// EVIDENCE COUPLING (CI arbitration note): +// Test 1 (dismissible:false ignores swipe) is a meaningful M1 regression guard +// ONLY in an environment where Test 2's premise holds (a swipe DOES dismiss a +// dismissible modal). In an environment where the swipe gesture is impotent +// (e.g. local iOS 26.5 sim, where Test 2 fails 3/3), Test 1 passes vacuously — +// with or without the M1 fix. Interpret Test 1 PASS + Test 2 FAIL as +// "no conclusion on M1" until Test 2 is green on the same run. + +import 'package:flutter/material.dart'; +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 { + debugPrint('SETUP → calling Purchasely.start()…'); + final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .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); + }); + + testWidgets( + 'modal(dismissible: false) ignores an interactive swipe-dismiss (M1 regression guard)', + (tester) async { + await tester.runAsync(() async { + PLYEvent? closedEvent; + Purchasely.listenToEvents((event) { + if (event.name == PLYEventName.PRESENTATION_CLOSED) { + closedEvent = event; + } + }); + + // onPresented is set on the BUILDER (like T7/T9 in dart_ios_bridge_test.dart + // / interceptor_trigger_ios_test.dart), not reassigned on the PLYPresentation + // returned by preload(): the bridge re-derives a fresh PLYPresentation + // from the request's callbacks on the very first onPresented dispatch + // (bridge.dart:_handleOnPresented), so a callback assigned directly on + // the preloaded handle is silently dropped for that first event. + var presented = false; + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + final presentation = await request.preload(); + + PLYPresentationOutcome? outcome; + // Fire-and-forget, like default_dismiss_via_display_ios_test.dart: we + // need to keep polling `outcome` rather than block on the future, since + // the whole point of this test is to prove it stays pending. + // ignore: unawaited_futures + presentation + .display(const PLYTransition.modal(dismissible: false)) + .then((o) => outcome = o); + + // 40s, not 20s: observed locally that Xcode (re)build + SDK start() + + // preload()'s backend round trip can push first-render past a tighter + // window, especially on a cold simulator. Widening this deadline only + // relaxes HOW LONG we wait for onPresented — it does not touch what's + // being asserted. + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'modal paywall should present'); + debugPrint('M1-NONDISMISSIBLE-READY'); + + // The concurrent driver waits for the readiness marker above, then sends + // 2 interactive swipe-down gestures. PR #136 fixed iOS `parseTransition` + // to forward `dismissible: false` into `.modal(dismissible:)` — before + // the fix, the transition was ALWAYS `.modal` (swipe-dismissible) + // regardless of the Dart flag. Give the driver time to act, then assert + // nothing moved. The driver's own AX-tree poll runs on a slower cadence + // than onPresented, so it gets a full 60s after the readiness marker to + // notice the paywall and complete 2 swipe gestures. The 65s wait below + // keeps this exact modal alive until that attempt has succeeded or + // failed; it cannot drift into the second test's paywall. + await Future.delayed(const Duration(seconds: 65)); + + expect(outcome, isNull, + reason: 'a non-dismissible modal must ignore the interactive swipe — ' + 'display() must still be pending'); + expect(closedEvent, isNull, + reason: 'PRESENTATION_CLOSED must not fire for an ignored swipe'); + + // Only a programmatic close should be able to dismiss it. + await presentation.close(); + final closeSw = Stopwatch()..start(); + while (outcome == null && closeSw.elapsed < const Duration(seconds: 15)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + expect(outcome, isNotNull, + reason: 'presentation.close() should resolve the pending display()'); + expect(outcome!.error, isNull); + expect( + outcome!.closeReason, + anyOf(PLYCloseReason.programmatic, PLYCloseReason.button, + PLYCloseReason.backSystem), + ); + debugPrint('modal(dismissible:false) → swipe ignored, programmatic ' + 'close → closeReason=${outcome!.closeReason}'); + }); + }); + + testWidgets( + 'modal(dismissible: true) resolves via an interactive swipe-dismiss', + (tester) async { + await tester.runAsync(() async { + // See the sibling test above: onPresented must be set on the BUILDER, + // not reassigned on the preloaded PLYPresentation handle. + var presented = false; + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + final presentation = await request.preload(); + + PLYPresentationOutcome? outcome; + Object? displayError; + StackTrace? displayStack; + // Fire-and-forget: the interactive swipe (not a Dart-side call) is what + // must resolve this future. + // ignore: unawaited_futures + presentation + .display(const PLYTransition.modal(dismissible: true)) + .then((o) => outcome = o, onError: (Object e, StackTrace st) { + displayError = e; + displayStack = st; + }); + + // See the sibling test above for why this is 40s, not 20s. + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'modal paywall should present'); + debugPrint('M1-DISMISSIBLE-READY'); + + // The concurrent driver (tools/swipe_dismiss_ios.sh) sends 1-2 + // interactive swipe-down gestures. A dismissible modal must let this + // dismiss it and resolve display(). + final sw = Stopwatch()..start(); + while (outcome == null && + displayError == null && + sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + + if (displayError != null) { + // Known local-only flake (iOS 26.5 sim): an interactive swipe + // dismiss has previously made display() fail outright instead of + // resolving. Log the full stack rather than weakening the + // assertions below — a persistent local failure here needs CI + // arbitration, not a softer test. + debugPrint('interactive swipe-dismiss → display() ERRORED: ' + '$displayError\n$displayStack'); + } + expect(displayError, isNull, + reason: 'display() must not error on an interactive swipe-dismiss'); + expect(outcome, isNotNull, + reason: 'a dismissible modal should resolve display() when swiped ' + 'away — driver: tools/swipe_dismiss_ios.sh'); + expect(outcome!.error, isNull); + expect( + outcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.button, + PLYCloseReason.programmatic), + ); + debugPrint('modal(dismissible:true) interactive swipe → ' + 'closeReason=${outcome!.closeReason}'); + }); + }); +} diff --git a/purchasely/example/integration_test/purchase_restore_android_test.dart b/purchasely/example/integration_test/purchase_restore_android_test.dart new file mode 100644 index 00000000..cdf4f0ae --- /dev/null +++ b/purchasely/example/integration_test/purchase_restore_android_test.dart @@ -0,0 +1,167 @@ +// E2E (S7 — StoreKit-equivalent purchase + restore, Android): honest-degradation +// suite for `emulator-5554`, which has NO Google Play Store / Play Billing +// service. Unlike the iOS S7 suite (purchase_restore_ios_test.dart), which +// completes a REAL local StoreKit2 transaction via Configuration.storekit, +// there is no equivalent "local billing" test double on Android — Play +// Billing has no offline/sandbox config file analogous to a `.storekit` +// file, and this CI/dev fleet has no device signed into a Play Store test +// track. +// +// ==> S7-Android (an actual completed purchase) is STRUCTURALLY BLOCKED on +// this fleet without a real Play-enabled device/emulator image. This +// suite does NOT fake that gap: it does not assert `purchased`, and it +// does not skip silently. It proves the two things that ARE true and +// observable here: +// +// 1. The purchase action interceptor still fires correctly on a real tap +// (the bridge + native paywall UI pipeline works right up to the point +// where Play Billing itself is unavailable) — this is NOT a hang. +// 2. `restoreAllProducts(timeout: 15s)` degrades CLEANLY — either +// resolving `false` or throwing a `TimeoutException` — never hanging +// past the bound. Both are asserted; a real infinite hang would fail +// this test via its own outer `-d emulator-5554` process timeout, not +// silently pass. +// +// Run together with the driver: +// (bash .../tap_purchase.sh emulator-5554 &) ; \ +// flutter test integration_test/purchase_restore_android_test.dart \ +// -d emulator-5554 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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 { + debugPrint('SETUP → calling Purchasely.start()…'); + final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .stores([PLYStore.google]) + .start() + .timeout(const Duration(seconds: 120), + onTimeout: () => + throw StateError('Purchasely.start() timed out after 120s'))); + debugPrint('SETUP → configured=$configured'); + expect(configured, isTrue); + }); + + testWidgets( + 'S7 — purchase interceptor fires, then fails/degrades cleanly (no Play Store); restore fast-fails', + (tester) async { + await tester.runAsync(() async { + PLYInterceptorInfo? capturedInfo; + PLYActionPayload? capturedPayload; + var presented = false; + + // notHandled: let the SDK attempt its own default purchase flow against + // real Play Billing — which is unavailable on this emulator. We are NOT + // asserting success; we're asserting the tap→interceptor pipeline works + // and that whatever happens next does not hang. + await Purchasely.interceptAction( + PLYPresentationActionKind.purchase, + (info, payload) async { + capturedInfo = info; + capturedPayload = payload; + return PLYInterceptResult.notHandled; + }, + ); + + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + final presentation = await request.preload(); + final displayFuture = + presentation.display(const PLYTransition.fullScreen()); + + final presentSw = Stopwatch()..start(); + while (!presented && presentSw.elapsed < const Duration(seconds: 20)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present'); + + // The concurrent driver (tap_purchase.sh) taps `action:purchase`. + final fireSw = Stopwatch()..start(); + while (capturedPayload == null && + fireSw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 300)); + } + expect(capturedPayload, isA(), + reason: 'purchase interceptor should fire on the native tap even ' + 'though the purchase itself cannot complete here'); + final purchase = capturedPayload as PLYPurchasePayload; + debugPrint('S7 Android → interceptor fired (notHandled → proceeding) ' + 'plan.vendorId=${purchase.plan.vendorId} ' + 'contentId=${capturedInfo?.contentId}'); + + // Without Play Billing, the SDK's default purchase attempt fails; the + // presentation may never resolve its display future on this fleet. Bound + // the wait — a clean bounded failure (error OR timeout), never an + // unbounded hang. + final purchaseSw = Stopwatch()..start(); + var purchaseHandledCleanly = false; + try { + final outcome = + await displayFuture.timeout(const Duration(seconds: 30)); + purchaseHandledCleanly = true; + expect(outcome.purchaseResult, isNot(PLYPurchaseResult.purchased), + reason: 'S7-Android is structurally blocked: no completed ' + 'purchase must ever be reported on this fleet'); + debugPrint('S7 Android → display resolved without a completed ' + 'purchase after ${purchaseSw.elapsedMilliseconds}ms: ' + 'purchaseResult=${outcome.purchaseResult} error=${outcome.error}'); + } on TimeoutException { + purchaseHandledCleanly = true; + debugPrint('S7 Android → display future bounded-timeout after ' + '${purchaseSw.elapsedMilliseconds}ms (expected: no Play Billing ' + 'to complete/cancel the purchase) — cleaning up locally'); + await presentation.close(); + } on PlatformException catch (e) { + purchaseHandledCleanly = true; + debugPrint('S7 Android → PlatformException(${e.code}): ${e.message} ' + 'after ${purchaseSw.elapsedMilliseconds}ms (expected: no Play ' + 'Billing on this emulator)'); + } + expect(purchaseHandledCleanly, isTrue, + reason: 'the purchase attempt must resolve, error, or time out ' + 'cleanly — never hang unboundedly'); + + await Purchasely.removeAllActionInterceptors(); + + // restoreAllProducts: fast, honest degradation. Never hangs past 15s. + final restoreSw = Stopwatch()..start(); + var restoreHandledCleanly = false; + try { + final restored = await Purchasely.restoreAllProducts( + timeout: const Duration(seconds: 15)); + restoreHandledCleanly = true; + expect(restored, isFalse, + reason: 'no Play Store on this emulator: nothing to restore'); + debugPrint('S7 Android → restoreAllProducts=$restored after ' + '${restoreSw.elapsedMilliseconds}ms'); + } on TimeoutException { + restoreHandledCleanly = true; + debugPrint('S7 Android → restoreAllProducts TimeoutException after ' + '${restoreSw.elapsedMilliseconds}ms (clean bounded failure, as ' + 'documented on restoreAllProducts)'); + } + expect(restoreHandledCleanly, isTrue, + reason: 'restoreAllProducts must return false or throw ' + 'TimeoutException — never hang past the 15s bound'); + expect(restoreSw.elapsed, lessThan(const Duration(seconds: 20)), + reason: 'restoreAllProducts must fast-fail, not stall near/above ' + 'its own timeout budget'); + }); + }); +} diff --git a/purchasely/example/integration_test/purchase_restore_ios_test.dart b/purchasely/example/integration_test/purchase_restore_ios_test.dart new file mode 100644 index 00000000..d773515e --- /dev/null +++ b/purchasely/example/integration_test/purchase_restore_ios_test.dart @@ -0,0 +1,167 @@ +// E2E (S7 — StoreKit restore degradation, iOS): proves the Flutter restore +// bridge completes quickly and honestly when a local StoreKit receipt cannot be +// verified by the real Purchasely backend. The separate +// interceptor_trigger_ios_test.dart suite owns the real-paywall-tap → typed +// purchase-interceptor contract. +// +// A full local purchase + successful restore is not a valid combination here: +// Xcode 26's hostless SKTestSession rejects the configured subscription with +// SKTestErrorDomain Code=1, and the real backend correctly rejects a local +// StoreKit test receipt with status 21002. Treating either result as a +// successful purchase would be a false green. This suite instead makes the +// supported contract explicit: restore returns false, the known verification +// error, or the wrapper's explicit TimeoutException within a strict bound; it +// must never hang the nightly job. +// +// --- Execution path (read before running) --------------------------------- +// +// StoreKit Testing configuration files (`Configuration.storekit`, wired into +// the shared `Runner.xcscheme`'s LaunchAction) ONLY apply when the app is +// actually LAUNCHED via that Xcode scheme. Plain `flutter test +// integration_test/x_test.dart -d ` installs and launches the app +// through `flutter_tools`' own device control (`xcrun simctl launch` +// directly), which never touches the Xcode scheme — so the local StoreKit +// config never attaches and any purchase attempt would hit the real +// (sandbox) App Store, which has no `com.purchasely.plus.*` products and no +// signed-in tester on this machine. +// +// The only path that launches the app through the scheme — and therefore +// actually applies Configuration.storekit — is `xcodebuild test`. Flutter +// does not run its own `integration_test` widget tests that way by default; +// the officially documented bridge (see the `integration_test` pub package +// README, "iOS Device Testing" / Firebase Test Lab section) is a **Unit +// Testing Bundle Xcode target with `TEST_HOST` set to the Runner app**, +// hosting the Flutter engine in-process, plus the `INTEGRATION_TEST_IOS_RUNNER` +// macro that turns each Dart test into a native XCTest method. This repo's +// existing `RunnerTests` target is deliberately HOSTLESS (see its own header +// comment + ci.yml: launching the Flutter engine that way previously +// SIGSEGV'd on headless CI simulators), so a SEPARATE target — +// `RunnerIntegrationTests` (purchasely/example/ios/RunnerIntegrationTests/) — +// was added specifically for this suite, additive and untouched otherwise. +// +// Run (see tools/run_storekit_suite_ios.sh for the scripted version): +// +// cd purchasely/example +// flutter build ios --config-only --simulator \ +// integration_test/purchase_restore_ios_test.dart +// cd ios && pod install +// (bash ../integration_test/tools/tap_purchase_ios.sh &) +// xcodebuild test -workspace Runner.xcworkspace -scheme Runner \ +// -only-testing:RunnerIntegrationTests -destination id= +// +// No separate driver taps the StoreKit purchase-confirmation sheet: +// RunnerIntegrationTests.m sets `SKTestSession.disableDialogs = YES`, which +// auto-confirms the purchase locally. That sheet is a SpringBoard-level +// system UI outside the app process anyway — idb's app-scoped +// `ui describe-all` targets the app under test, not SpringBoard, so driving +// it would need a different (and flakier) mechanism for no additional signal +// here: this suite is proving the SDK's restore flow, not Apple's +// confirmation dialog. +// +// CI implication (for Task 7): RunnerIntegrationTests is a hostless UI-test +// bundle that launches the app via XCUIApplication().launch(). Unlike the +// TEST_HOST-based RunnerTests (which SIGSEGV'd on headless CI simulators), +// this mechanism may behave differently on CI; CI behavior must be observed +// before trusting it. Task 7 should treat it as best-effort/non-blocking +// (like the other idb-driven suites in ci_run_e2e_ios.sh) until proven +// stable on the actual CI runner image. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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'; +// Greptile P1 (PR #138): RunnerIntegrationTests.m is a hostless XCTest bundle +// (see its own header) — xcodebuild's exit code only proves the app launched +// and exited/timed out, never whether the `expect()`s below actually passed. +// So this suite reports its own result explicitly: `_completedTests` is +// bumped as the LAST line of each test body (if a test throws — an +// `expect()` failure or anything else — before reaching that line, it never +// counts), and `tearDownAll` below prints exactly one grep'able marker line +// that tools/run_storekit_suite_ios.sh gates the build on. +int _completedTests = 0; +// ponytail: hardcoded to the single testWidgets() below; bump this (and add +// a matching `_completedTests++` as the new test's last line) if a second +// test is ever added to this file. +const int _kTotalTests = 1; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + tearDownAll(() { + if (_completedTests == _kTotalTests) { + debugPrint('S7-IOS-RESULT: PASS'); + } else { + debugPrint( + 'S7-IOS-RESULT: FAIL (completed=$_completedTests/$_kTotalTests)'); + } + }); + + setUpAll(() async { + debugPrint('SETUP → calling Purchasely.start()…'); + 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'); + }); + + testWidgets('S7 — local receipt restore degrades honestly without hanging', + (tester) async { + await tester.runAsync(() async { + final stopwatch = Stopwatch()..start(); + bool? restored; + PlatformException? verificationError; + TimeoutException? timeoutError; + try { + restored = await Purchasely.restoreAllProducts( + timeout: const Duration(seconds: 15)); + } on PlatformException catch (error) { + verificationError = error; + } on TimeoutException catch (error) { + timeoutError = error; + } + stopwatch.stop(); + + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 20)), + reason: 'restore must fail fast, never stall the E2E runner'); + if (verificationError != null) { + expect(verificationError.code, '-1'); + expect(verificationError.message, 'Restore failed'); + expect(verificationError.details, isA()); + expect( + verificationError.details, contains('Receipt verification failed')); + expect(verificationError.details, contains('[21002]')); + debugPrint('S7 iOS → expected local receipt rejection in ' + '${stopwatch.elapsedMilliseconds}ms: ${verificationError.details}'); + } else if (timeoutError != null) { + expect(stopwatch.elapsed, + greaterThanOrEqualTo(const Duration(seconds: 15)), + reason: 'the configured timeout must be the bound that fired'); + debugPrint('S7 iOS → restore bounded timeout in ' + '${stopwatch.elapsedMilliseconds}ms: $timeoutError'); + } else { + expect(restored, isFalse, + reason: 'an empty local StoreKit session has nothing to restore'); + debugPrint('S7 iOS → restoreAllProducts=false in ' + '${stopwatch.elapsedMilliseconds}ms'); + } + + // Last line of the test body, deliberately: see the module-level + // comment on `_completedTests` above. + _completedTests++; + }); + }); +} diff --git a/purchasely/example/integration_test/re_display_ios_test.dart b/purchasely/example/integration_test/re_display_ios_test.dart new file mode 100644 index 00000000..dc051bdb --- /dev/null +++ b/purchasely/example/integration_test/re_display_ios_test.dart @@ -0,0 +1,233 @@ +// E2E: re-display of the SAME preloaded PLYPresentation handle keeps showing +// the ORIGINAL presentation source (PR #136 fix M2 — "re-display iOS perd la +// source"). iOS mirror of re_display_test.dart. +// +// `bridge.dart`'s `_displayPresentation` (handle-based `PLYPresentation.display()`) +// resends the ORIGINAL request `source` retained in `_originSources` +// (bridge.dart:102-108, 216-219) rather than inferring a source from the +// loaded presentation — inference would pin a dynamic default source to +// whatever screen it happened to resolve to, bypassing updated targeting on a +// native rebuild (commit b0a313c). On iOS, `SwiftPurchaselyFlutterPlugin` +// keeps the native `request` for a requestId alive across a dismiss +// (`loadedPresentations` is cleared, `requests` is not — +// SwiftPurchaselyFlutterPlugin.swift:397-411), so this suite exercises the +// end-to-end observable contract: under stable targeting, re-displaying the +// same handle must render the same screen. +// +// Identity signal used below: `PLYPresentationOutcome.presentation`, delivered +// on every `onDismissed` event (`bridge.dart:_outcomeFromMap`) and re-parsed +// from native's wire data on EACH dismiss. This is deliberately NOT the +// `presentation` argument handed to `onPresented`: on a re-display the +// originating request is gone (dropped after the first dismiss), so +// `_handleOnPresented` (bridge.dart:358-369) reuses the cached Dart handle +// as-is instead of re-deriving it from the fresh native payload — comparing +// that field across cycles would trivially always match the SAME Dart object +// and prove nothing about what native actually rendered. +// +// This suite drives TWO display cycles on the same handle, so the driver must +// run TWICE, chained: +// (SUITE_LOG=/tmp/re-display.log \ +// bash integration_test/tools/re_display_driver_ios.sh ) & +// flutter test integration_test/re_display_ios_test.dart -d + +import 'package:flutter/material.dart'; +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 { + debugPrint('SETUP → calling Purchasely.start()…'); + final configured = await startWithRetry(() => Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .allowDeeplink(true) + .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); + }); + + testWidgets( + 're-display() of the same handle shows the same presentation, not the ' + "placement's default", (tester) async { + await tester.runAsync(() async { + // Independent cross-check (secondary to the outcome-based assertion + // below): PRESENTATION_VIEWED can be deduplicated per session for a + // screen already shown (observed in dart_ios_bridge_test.dart T10), + // so this is compared, not primary — but it must still be a HARD + // assertion (guarded below), not a diagnostic no-op, or it would + // reopen the exact vacuous-comparison hole this suite guards against. + final viewedIds = []; + Purchasely.listenToEvents((event) { + if (event.name == PLYEventName.PRESENTATION_VIEWED || + event.name == PLYEventName.PRESENTATION_LOADED) { + viewedIds.add(event.properties.displayed_presentation); + } + }); + + var presented = false; + // onPresented MUST be set on the BUILDER, not reassigned on the + // PLYPresentation returned by preload() — a callback reassigned + // directly on the handle is silently dropped for the very first + // display cycle (bridge.dart:_handleOnPresented; see Task 2 report). + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + final presentation = await request.preload(); + + expect(presentation.type, PLYPresentationType.normal, + reason: 'placement must resolve to a live screen (not a ' + 'fallback/deactivated one) for this identity test to be ' + 'meaningful'); + expect(presentation.screenId, isNotNull); + expect(presentation.screenId, isNotEmpty); + expect(presentation.placementId, equals(kPlacementAudiences)); + debugPrint('preload → screenId=${presentation.screenId} ' + 'placementId=${presentation.placementId} type=${presentation.type}'); + + // --- Cycle 1: display → onPresented → driver closes → outcome ------- + PLYPresentationOutcome? firstOutcome; + Object? firstDisplayError; + // ignore: unawaited_futures + presentation.display().then((o) => firstOutcome = o, + onError: (Object e, StackTrace st) => firstDisplayError = e); + + var sw = Stopwatch()..start(); + while (!presented && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present (cycle 1)'); + debugPrint('REDISPLAY-CYCLE-1-READY'); + + sw = Stopwatch()..start(); + while (firstOutcome == null && + firstDisplayError == null && + sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(firstDisplayError, isNull, + reason: 'display() must not error on driver-close (cycle 1)'); + expect(firstOutcome, isNotNull, + reason: 'driver (re_display_driver_ios.sh, cycle 1) should ' + 'close the paywall and resolve display()'); + expect(firstOutcome!.error, isNull); + expect(firstOutcome!.presentation?.screenId, isNotNull); + expect(firstOutcome!.presentation?.screenId, isNotEmpty); + expect( + firstOutcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + debugPrint( + 'cycle 1 (first display) → closeReason=${firstOutcome!.closeReason} ' + 'screenId=${firstOutcome!.presentation?.screenId} ' + 'placementId=${firstOutcome!.presentation?.placementId}'); + + // Snapshot the event stream collected so far so cycle 2's events can + // be isolated below (list only grows — no restructuring needed). + final cycle1ViewedIds = List.from(viewedIds); + + // --- Cycle 2: RE-display the SAME handle ------------------------------ + presented = false; + PLYPresentationOutcome? secondOutcome; + Object? secondDisplayError; + // ignore: unawaited_futures + presentation.display().then((o) => secondOutcome = o, + onError: (Object e, StackTrace st) => secondDisplayError = e); + + sw = Stopwatch()..start(); + while (!presented && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, + reason: 'paywall should present again on re-display (cycle 2)'); + debugPrint('REDISPLAY-CYCLE-2-READY'); + + sw = Stopwatch()..start(); + while (secondOutcome == null && + secondDisplayError == null && + sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + if (secondDisplayError != null) { + // Known local-only class of flake on iOS 26.5 sim + idb (see Task 2 + // report): an interactive dismiss has previously errored the display() + // future instead of resolving it. Log the full detail rather than + // weakening the assertions below — a persistent local failure needs + // CI arbitration, not a softer test. + debugPrint('cycle 2 (re-display) → display() ERRORED: ' + '$secondDisplayError'); + } + expect(secondDisplayError, isNull, + reason: 'display() must not error on driver-close (cycle 2 — ' + 're-display)'); + expect(secondOutcome, isNotNull, + reason: 'driver (re_display_driver_ios.sh, cycle 2) should ' + 'close the re-displayed paywall and resolve display()'); + expect(secondOutcome!.error, isNull); + expect( + secondOutcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + debugPrint( + 'cycle 2 (re-display) → closeReason=${secondOutcome!.closeReason} ' + 'screenId=${secondOutcome!.presentation?.screenId} ' + 'placementId=${secondOutcome!.presentation?.placementId}'); + debugPrint('PRESENTATION_VIEWED/LOADED displayed_presentation per ' + 'cycle: $viewedIds'); + + // --- The M2 assertion: same handle → same screen, every cycle --------- + // Primary check. + expect( + secondOutcome!.presentation?.screenId, + equals(firstOutcome!.presentation?.screenId), + reason: 'M2 regression guard: re-displaying the same handle must ' + 'show the SAME screen as the first display, not fall back to ' + "the placement's default source", + ); + expect( + secondOutcome!.presentation?.placementId, + equals(firstOutcome!.presentation?.placementId), + ); + + // Independent cross-check via the event stream (not the outcome + // parsing path being guarded above). Guarded so missing/unusable data + // FAILS loudly rather than silently no-oping — an independent check + // that can't fail proves nothing. + final cycle2ViewedIds = viewedIds.sublist(cycle1ViewedIds.length); + expect(cycle1ViewedIds, isNotEmpty, + reason: 'cycle 1 should have produced at least one ' + 'PRESENTATION_VIEWED/LOADED event to cross-check against — ' + 'if this fails, the event-stream cross-check has no baseline'); + expect(cycle1ViewedIds.last, isNotNull); + expect(cycle1ViewedIds.last, isNotEmpty); + expect(cycle2ViewedIds, isNotEmpty, + reason: 'cycle 2 (re-display) should have produced at least one ' + 'PRESENTATION_VIEWED/LOADED event — an event-stream ' + 'cross-check with nothing to compare would silently no-op ' + 'instead of catching a regression'); + expect( + cycle2ViewedIds.last, + equals(cycle1ViewedIds.last), + reason: 'event-stream cross-check: cycle 2\'s PRESENTATION_VIEWED/' + 'LOADED displayed_presentation id must equal cycle 1\'s ' + '(independent corroboration of the outcome-based assertion ' + 'above)', + ); + + Purchasely.stopListeningToEvents(); + }); + }); +} diff --git a/purchasely/example/integration_test/re_display_test.dart b/purchasely/example/integration_test/re_display_test.dart new file mode 100644 index 00000000..dd4b6442 --- /dev/null +++ b/purchasely/example/integration_test/re_display_test.dart @@ -0,0 +1,199 @@ +// E2E: re-display of the SAME preloaded PLYPresentation handle keeps showing +// the ORIGINAL presentation source (PR #136 fix M2 — "re-display iOS perd la +// source"). +// +// `bridge.dart`'s `_displayPresentation` (handle-based `PLYPresentation.display()`) +// resends the ORIGINAL request `source` retained in `_originSources` +// (bridge.dart:102-108, 216-219) rather than inferring a source from the +// loaded presentation — inference would pin a dynamic default source to +// whatever screen it happened to resolve to, bypassing updated targeting on a +// native rebuild (commit b0a313c). +// +// Identity signal used below: `PLYPresentationOutcome.presentation`, delivered +// on every `onDismissed` event (`bridge.dart:_outcomeFromMap`) and re-parsed +// from native's wire data on EACH dismiss. This is deliberately NOT the +// `presentation` argument handed to `onPresented`: on a re-display the +// originating request is gone (dropped after the first dismiss), so +// `_handleOnPresented` (bridge.dart:358-369) reuses the cached Dart handle +// as-is instead of re-deriving it from the fresh native payload — comparing +// that field across cycles would trivially always match the SAME Dart object +// and prove nothing about what native actually rendered. +// +// This suite drives TWO display cycles on the same handle, so the driver must +// run TWICE, chained: +// (bash integration_test/tools/press_back.sh emulator-5554 ; \ +// bash integration_test/tools/press_back.sh emulator-5554) & +// flutter test integration_test/re_display_test.dart -d emulator-5554 + +import 'package:flutter/material.dart'; +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 startWithRetry(() => Purchasely.apiKey(kApiKey) + .runningMode(PLYRunningMode.full) + .logLevel(PLYLogLevel.debug) + .stores([PLYStore.google]).start()); + expect(configured, isTrue, + reason: 'SDK should configure against the real backend'); + }); + + testWidgets( + 're-display() of the same handle shows the same presentation, not the ' + "placement's default", (tester) async { + await tester.runAsync(() async { + // Independent cross-check (secondary to the outcome-based assertion + // below): PRESENTATION_VIEWED can be deduplicated per session for a + // screen already shown (observed in dart_android_bridge_test.dart + // T10), so this is compared, not primary — but it must still be a + // HARD assertion (guarded below), not a diagnostic no-op, or it would + // reopen the exact vacuous-comparison hole this suite guards against. + final viewedIds = []; + Purchasely.listenToEvents((event) { + if (event.name == PLYEventName.PRESENTATION_VIEWED || + event.name == PLYEventName.PRESENTATION_LOADED) { + viewedIds.add(event.properties.displayed_presentation); + } + }); + + var presented = false; + // onPresented MUST be set on the BUILDER, not reassigned on the + // PLYPresentation returned by preload() — a callback reassigned + // directly on the handle is silently dropped for the very first + // display cycle (bridge.dart:_handleOnPresented; see Task 2 report). + final request = PLYPresentationBuilder.placement(kPlacementAudiences) + .onPresented((p, e) => presented = true) + .build(); + final presentation = await request.preload(); + + expect(presentation.type, PLYPresentationType.normal, + reason: 'placement must resolve to a live screen (not a ' + 'fallback/deactivated one) for this identity test to be ' + 'meaningful'); + expect(presentation.screenId, isNotNull); + expect(presentation.screenId, isNotEmpty); + expect(presentation.placementId, equals(kPlacementAudiences)); + debugPrint('preload → screenId=${presentation.screenId} ' + 'placementId=${presentation.placementId} type=${presentation.type}'); + + // --- Cycle 1: display → onPresented → driver closes → outcome ------- + PLYPresentationOutcome? firstOutcome; + // ignore: unawaited_futures + presentation.display().then((o) => firstOutcome = o); + + var sw = Stopwatch()..start(); + while (!presented && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, reason: 'paywall should present (cycle 1)'); + + sw = Stopwatch()..start(); + while (firstOutcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(firstOutcome, isNotNull, + reason: 'driver (press_back.sh, 1st invocation) should close the ' + 'paywall and resolve display()'); + expect(firstOutcome!.error, isNull); + expect(firstOutcome!.presentation?.screenId, isNotNull); + expect(firstOutcome!.presentation?.screenId, isNotEmpty); + expect( + firstOutcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + debugPrint( + 'cycle 1 (first display) → closeReason=${firstOutcome!.closeReason} ' + 'screenId=${firstOutcome!.presentation?.screenId} ' + 'placementId=${firstOutcome!.presentation?.placementId}'); + + // Snapshot the event stream collected so far so cycle 2's events can + // be isolated below (list only grows — no restructuring needed). + final cycle1ViewedIds = List.from(viewedIds); + + // --- Cycle 2: RE-display the SAME handle ------------------------------ + presented = false; + PLYPresentationOutcome? secondOutcome; + // ignore: unawaited_futures + presentation.display().then((o) => secondOutcome = o); + + sw = Stopwatch()..start(); + while (!presented && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(presented, isTrue, + reason: 'paywall should present again on re-display (cycle 2)'); + + sw = Stopwatch()..start(); + while ( + secondOutcome == null && sw.elapsed < const Duration(seconds: 40)) { + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(secondOutcome, isNotNull, + reason: 'driver (press_back.sh, 2nd invocation) should close the ' + 're-displayed paywall and resolve display()'); + expect(secondOutcome!.error, isNull); + expect( + secondOutcome!.closeReason, + anyOf(PLYCloseReason.backSystem, PLYCloseReason.programmatic, + PLYCloseReason.button), + ); + debugPrint( + 'cycle 2 (re-display) → closeReason=${secondOutcome!.closeReason} ' + 'screenId=${secondOutcome!.presentation?.screenId} ' + 'placementId=${secondOutcome!.presentation?.placementId}'); + debugPrint('PRESENTATION_VIEWED/LOADED displayed_presentation per ' + 'cycle: $viewedIds'); + + // --- The M2 assertion: same handle → same screen, every cycle --------- + // Primary check. + expect( + secondOutcome!.presentation?.screenId, + equals(firstOutcome!.presentation?.screenId), + reason: 'M2 regression guard: re-displaying the same handle must ' + 'show the SAME screen as the first display, not fall back to ' + "the placement's default source", + ); + expect( + secondOutcome!.presentation?.placementId, + equals(firstOutcome!.presentation?.placementId), + ); + + // Independent cross-check via the event stream (not the outcome + // parsing path being guarded above). Guarded so missing/unusable data + // FAILS loudly rather than silently no-oping — an independent check + // that can't fail proves nothing. + final cycle2ViewedIds = viewedIds.sublist(cycle1ViewedIds.length); + expect(cycle1ViewedIds, isNotEmpty, + reason: 'cycle 1 should have produced at least one ' + 'PRESENTATION_VIEWED/LOADED event to cross-check against — ' + 'if this fails, the event-stream cross-check has no baseline'); + expect(cycle1ViewedIds.last, isNotNull); + expect(cycle1ViewedIds.last, isNotEmpty); + expect(cycle2ViewedIds, isNotEmpty, + reason: 'cycle 2 (re-display) should have produced at least one ' + 'PRESENTATION_VIEWED/LOADED event — an event-stream ' + 'cross-check with nothing to compare would silently no-op ' + 'instead of catching a regression'); + expect( + cycle2ViewedIds.last, + equals(cycle1ViewedIds.last), + reason: 'event-stream cross-check: cycle 2\'s PRESENTATION_VIEWED/' + 'LOADED displayed_presentation id must equal cycle 1\'s ' + '(independent corroboration of the outcome-based assertion ' + 'above)', + ); + + Purchasely.stopListeningToEvents(); + }); + }); +} diff --git a/purchasely/example/integration_test/tools/ci_run_e2e.sh b/purchasely/example/integration_test/tools/ci_run_e2e.sh index 665e69eb..5f576bda 100755 --- a/purchasely/example/integration_test/tools/ci_run_e2e.sh +++ b/purchasely/example/integration_test/tools/ci_run_e2e.sh @@ -3,93 +3,232 @@ # emulator has booted (see .github/workflows/e2e-android.yml). Tees per-suite logs # to integration_test/ci-logs/ for artifact upload. # -# Gating model: -# * bridge (T1–T20, no native interaction) = HARD gate. Deterministic once the -# SDK starts; retried for robustness. -# * interceptor / dismiss = BEST-EFFORT (non-blocking). They drive a real -# uiautomator tap / system BACK on the custom-rendered paywall, which is -# inherently flaky on the CI emulator. Run for signal; a failure emits a -# warning but does NOT fail the job. +# Gating model: ALL suites are HARD gates. (The previous best-effort / +# silent-::warning:: model for the uiautomator-driven suites is forbidden by +# the mission — a suite that never fails a build is not a test. The green +# baseline run analyzed in the Task 7 CI-hang diagnosis showed these suites +# already pass within the existing 3-attempt retry budget, consuming at most +# one retry for normal emulator flakiness — promoting them to hard gates does +# not require any suite-level redesign.) +# +# Per-attempt timeout: each `flutter test` invocation is bounded to $TIMEOUT +# seconds (default 600, override via env e.g. `TIMEOUT=5 ...` for local +# debugging of the watchdog itself). GNU `timeout` is not assumed to be +# present (this script also runs, unmodified in structure, as the model for +# ci_run_e2e_ios.sh on the macOS runner, which has no `timeout` at all) — see +# run_with_timeout() below for the portable bash implementation. The +# existing 3x retry loop applies to a timed-out attempt exactly like any +# other failure. +# +# Root cause addressed by this file (see Task 7 diagnosis, +# scratchpad ci-hang-diagnosis.md): this script's retry/loop logic was never +# the bug. The hang was `.github/workflows/e2e-android.yml` pinning +# flutter-version 3.24.x post-AGP9 (#130), which broke `compileGroovy` on +# Gradle 9 in ~7s but only surfaced 12 minutes later via flutter_tools' +# generic per-test timeout. That's fixed in the workflow file itself +# (flutter-version bumped to 3.44.0). The watchdog here is the safety net so +# the NEXT such regression fails fast and loud instead of silently eating the +# 60-minute job budget one 12-minute TimeoutException at a time. set -uo pipefail DEV="${1:-emulator-5554}" +TIMEOUT="${TIMEOUT:-600}" # seconds per suite ATTEMPT (not per suite overall) HERE="$(cd "$(dirname "$0")" && pwd)" -EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example -cd "$EXAMPLE_DIR" +EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example +cd "$EXAMPLE_DIR" || exit 1 LOGS="integration_test/ci-logs" mkdir -p "$LOGS" adb -s "$DEV" wait-for-device +# Suppress the one-time "Immersive mode confirmation" system dialog a fresh +# AVD shows the first time any app goes fullscreen — one less window +# contending for focus alongside the launcher-ANR condition diagnosed in +# task-9-android-report.md (identical mCurrentFocus window IDs for this +# dialog were observed pinned across 5 different failing suites in one CI +# run). Best-effort: harmless if the setting doesn't exist on this API level. +adb -s "$DEV" shell settings put secure immersive_mode_confirmations confirmed 2>/dev/null || true flutter pub get -# Run a suite up to 3×; pass if any attempt passes. $3 = optional driver script. +# Runs "$@" with a hard $TIMEOUT-second ceiling. Portable (no dependency on +# GNU coreutils' `timeout`, which the macOS E2E-iOS runner lacks by default): +# backgrounds "$@", races it against a `sleep $TIMEOUT` watchdog, and kills +# whichever loses. Returns 124 on timeout (matches GNU timeout's convention), +# else "$@"'s own exit code. +# +# TREE KILL: "$@" is backgrounded with job control (`set -m`) enabled so bash +# gives it its own process group (PGID == its own PID) — standard POSIX job +# control, identical on GNU bash 3.2/macOS and bash 5.x/Linux (unlike +# `setsid`/GNU `timeout`, this isn't a coreutils-only feature, so it needs no +# per-runner branching). On timeout we kill the *group* +# (`kill -TERM -- "-$cmd_pid"`), not just $cmd_pid, so children reparented +# under it (gradle/dart/xcodebuild, background taps) are reached too, not +# just the immediate `flutter test` process. `set -m` is scoped to only the +# backgrounding line so it doesn't change job-control semantics anywhere +# else in this function (incl. the pipe-hang fix below). LIMITATION: a +# Gradle daemon detaches into its own session by design (so it survives its +# launching process) and therefore escapes this process group — that daemon +# is shared/pre-existing across attempts, not per-attempt state, so it isn't +# something this kill needs to reach. +# +# NOTE (found via local testing, see task-7-report.md): when this whole +# function is used as the left side of a pipe (`run_with_timeout ... | tee +# log`, exactly how it's called below), killing ONLY the watchdog subshell's +# own PID leaves its `sleep $TIMEOUT` child orphaned (reparented, not +# reaped) — and since that orphan still holds the pipe's write end open, the +# downstream `tee` never sees EOF and the whole pipeline hangs for the +# remainder of $TIMEOUT even on a perfectly healthy, fast-passing attempt. +# `pkill -P "$watchdog_pid"` (kill its child by parent-pid) BEFORE killing +# the subshell itself avoids this — verified with an isolated repro. (An +# earlier attempt to also `disown` the backgrounded "$@" job, to silence +# bash's cosmetic job-control "Terminated" notice, reintroduced this exact +# class of race on the fast/no-timeout path — dropped; the notice is +# harmless log noise, left as-is.) +run_with_timeout() { + local marker + marker="$(mktemp)" + set -m + "$@" & + local cmd_pid=$! + set +m + ( + sleep "$TIMEOUT" + if kill -0 "$cmd_pid" 2>/dev/null; then + echo "::warning::watchdog: attempt exceeded ${TIMEOUT}s, killing PGID $cmd_pid" + echo 1 >"$marker" + kill -TERM -- "-$cmd_pid" 2>/dev/null + sleep 5 + kill -KILL -- "-$cmd_pid" 2>/dev/null + fi + ) & + local watchdog_pid=$! + local status=0 + wait "$cmd_pid" 2>/dev/null || status=$? + pkill -P "$watchdog_pid" 2>/dev/null # reap the watchdog's sleep child first (see NOTE above) + kill "$watchdog_pid" 2>/dev/null + wait "$watchdog_pid" 2>/dev/null + if [ -s "$marker" ]; then + status=124 + fi + rm -f "$marker" + return "$status" +} + +# Run a suite up to 3x; pass if any attempt passes. $3 = optional driver script +# (invoked as `driver `; the per-attempt suite log path is exported as +# $SUITE_LOG for drivers that need to synchronize against it, e.g. a +# multi-tap driver polling for a marker line — see interceptor_actions_driver_ios.sh +# on the iOS side for the pattern this generalizes). run_suite() { local label="$1" testfile="$2" driver="$3" logbase="$4" local attempts=3 dpid="" for a in $(seq 1 "$attempts"); do - echo "=== $label (attempt $a/$attempts) ===" + echo "::group::SUITE $label attempt $a" + local start_ts end_ts duration status + start_ts=$(date +%s) dpid="" if [ -n "$driver" ]; then - bash "$HERE/$driver" "$DEV" > "$LOGS/${logbase}_driver_$a.log" 2>&1 & + export SUITE_LOG="$LOGS/${logbase}_$a.log" + bash "$HERE/$driver" "$DEV" >"$LOGS/${logbase}_driver_$a.log" 2>&1 & dpid=$! fi - if flutter test "$testfile" -d "$DEV" 2>&1 | tee "$LOGS/${logbase}_$a.log"; then - cp "$LOGS/${logbase}_$a.log" "$LOGS/${logbase}.log" 2>/dev/null || true - [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true + status=0 + run_with_timeout flutter test "$testfile" -d "$DEV" --no-pub --reporter expanded 2>&1 | tee "$LOGS/${logbase}_$a.log" + status=$? + end_ts=$(date +%s) + duration=$((end_ts - start_ts)) + if [ "$status" -eq 124 ]; then + echo "SUITE $label attempt $a: exit=124 (TIMEOUT after ${TIMEOUT}s) duration=${duration}s" + else + echo "SUITE $label attempt $a: exit=$status duration=${duration}s" + fi + echo "::endgroup::" + if [ "$status" -eq 0 ]; then + if ! cp "$LOGS/${logbase}_$a.log" "$LOGS/${logbase}.log" 2>/dev/null; then + echo "[cleanup] failed to copy ${logbase}_$a.log (non-fatal)" + fi + if [ -n "$dpid" ]; then + kill "$dpid" 2>/dev/null || echo "[cleanup] driver pid $dpid already exited (non-fatal)" + fi echo "=== $label passed on attempt $a ===" return 0 fi - [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true - echo "=== $label failed attempt $a ===" - adb -s "$DEV" shell am force-stop com.purchasely.demo 2>/dev/null || true + if [ -n "$dpid" ]; then + kill "$dpid" 2>/dev/null || echo "[cleanup] driver pid $dpid already exited (non-fatal)" + fi + echo "=== $label failed attempt $a (exit=$status) ===" + if ! adb -s "$DEV" shell am force-stop com.purchasely.demo 2>/dev/null; then + echo "[cleanup] force-stop failed (non-fatal)" + fi sleep 3 done - cp "$LOGS/${logbase}_${attempts}.log" "$LOGS/${logbase}.log" 2>/dev/null || true + if ! cp "$LOGS/${logbase}_${attempts}.log" "$LOGS/${logbase}.log" 2>/dev/null; then + echo "[cleanup] failed to copy ${logbase}_${attempts}.log (non-fatal)" + fi return 1 } fail=0 -echo "=== Suite 1/8: Dart↔Android bridge (T1–T20) — HARD gate ===" +echo "=== Suite 1/12: Dart<->Android bridge (T1-T20) — HARD gate ===" run_suite "bridge" integration_test/dart_android_bridge_test.dart "" bridge || fail=1 -echo "=== Suite 2/8: cold-start deeplink (builder.handleDeeplink → auto-open) — HARD gate ===" +echo "=== Suite 2/12: cold-start deeplink (builder.handleDeeplink -> auto-open) — HARD gate ===" # Deterministic: the SDK opens the paywall itself from the cold-start deeplink and # the test only asserts on analytics events (no flaky uiautomator driver). run_suite "deeplink_cold_start" integration_test/deeplink_cold_start_test.dart "" deeplink_cold_start || fail=1 -echo "=== Suite 3/8: user-attribute listener (set/removed events) — HARD gate ===" +echo "=== Suite 3/12: user-attribute listener (set/removed events) — HARD gate ===" # Deterministic: setting/clearing an attribute makes the native SDK emit a change # event the listener must receive (no UI interaction, no driver). run_suite "user_attribute_listener" integration_test/user_attribute_listener_test.dart "" user_attribute_listener || fail=1 -echo "=== Suite 4/8: interceptor trigger (uiautomator tap) — best-effort ===" +echo "=== Suite 4/12: interceptor trigger (uiautomator tap) — HARD gate ===" run_suite "interceptor" integration_test/interceptor_trigger_test.dart \ - tap_purchase.sh interceptor \ - || echo "::warning::E2E Android interceptor suite failed after retries (non-blocking)" + tap_purchase.sh interceptor || fail=1 -echo "=== Suite 5/8: default dismiss handler via deeplink (system BACK) — best-effort ===" +echo "=== Suite 5/12: default dismiss handler via deeplink (system BACK) — HARD gate ===" run_suite "dismiss" integration_test/default_dismiss_handler_test.dart \ - press_back.sh dismiss \ - || echo "::warning::E2E Android dismiss suite failed after retries (non-blocking)" + press_back.sh dismiss || fail=1 -echo "=== Suite 6/8: default dismiss handler via fire-and-forget display() (system BACK) — best-effort ===" +echo "=== Suite 6/12: default dismiss handler via fire-and-forget display() (system BACK) — HARD gate ===" run_suite "dismiss_via_display" integration_test/default_dismiss_via_display_test.dart \ - press_back.sh dismiss_via_display \ - || echo "::warning::E2E Android dismiss-via-display suite failed after retries (non-blocking)" + press_back.sh dismiss_via_display || fail=1 -echo "=== Suite 7/8: local dismiss handler wins over default (system BACK) — best-effort ===" +echo "=== Suite 7/12: local dismiss handler wins over default (system BACK) — HARD gate ===" run_suite "local_dismiss" integration_test/local_dismiss_handler_test.dart \ - press_back.sh local_dismiss \ - || echo "::warning::E2E Android local-dismiss suite failed after retries (non-blocking)" - -echo "=== Suite 8/8: inline view keeps the global event stream flowing (FLT-W-12) — best-effort ===" -# No native driver — deterministic once the inline platform view renders. Kept -# non-blocking until the inline-render path is proven reliable on the CI -# emulator; promote to a HARD gate (|| fail=1) once it is consistently green. -run_suite "inline_events" integration_test/inline_events_test.dart "" inline_events \ - || echo "::warning::E2E Android inline-events suite failed after retries (non-blocking)" + press_back.sh local_dismiss || fail=1 + +echo "=== Suite 8/12: inline view keeps the global event stream flowing (FLT-W-12) — HARD gate ===" +run_suite "inline_events" integration_test/inline_events_test.dart "" inline_events || fail=1 + +echo "=== Suite 9/12: inline PLYPresentationView render path (preload/mount/present) — HARD gate ===" +# No native driver — deterministic once the inline platform view renders (see +# inline_paywall_test.dart's own header for why the close/x flow is verified +# separately, in the real app, not here). +run_suite "inline_paywall" integration_test/inline_paywall_test.dart "" inline_paywall || fail=1 + +echo "=== Suite 10/12: re-display of the same handle keeps the ORIGINAL source (PR #136 M2) — HARD gate ===" +# Driver must run TWICE, chained (two display cycles on the same handle) — +# see re_display_test.dart's header. re_display_driver.sh does the chaining. +run_suite "re_display" integration_test/re_display_test.dart \ + re_display_driver.sh re_display || fail=1 + +echo "=== Suite 11/12: Flow display + dismiss (S2, integration_test_flow) — HARD gate ===" +# flow_close_all.sh drives pattern (A) (direct action:close_all tap from +# "calm"); the Dart suite self-recovers via Purchasely.closeAllScreens() if +# navigation happens first or the tap isn't observed — see that file's +# header/body for why a single driver call is sufficient here. +run_suite "flow_dismiss" integration_test/flow_dismiss_test.dart \ + flow_close_all.sh flow_dismiss || fail=1 + +echo "=== Suite 12/12: S7 purchase interceptor + restore, honest degradation (no Play Billing) — HARD gate ===" +# Structurally cannot complete a real purchase on this emulator (no Play +# Store); proves the interceptor fires on a real tap and restoreAllProducts +# degrades cleanly within its own bound — see purchase_restore_android_test.dart +# header and task-6-report.md. Deterministic, ~70s wall time. +run_suite "purchase_restore_android" integration_test/purchase_restore_android_test.dart \ + tap_purchase.sh purchase_restore_android || fail=1 echo "=== E2E Android finished (gating fail=$fail) ===" exit $fail diff --git a/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh b/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh index 02572bf6..15dc8c78 100755 --- a/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh +++ b/purchasely/example/integration_test/tools/ci_run_e2e_ios.sh @@ -1,97 +1,287 @@ #!/bin/bash -# CI entrypoint for the iOS E2E suite. Runs the three iOS test files on the +# CI entrypoint for the iOS E2E suite. Runs the E2E test files on the # booted simulator passed as $1. Tees logs to integration_test/ci-logs/. # # Usage: bash ci_run_e2e_ios.sh # -# Gating model: -# * bridge (T1–T20, no native interaction) = HARD gate. Deterministic once the -# SDK starts; retried because Purchasely.start() occasionally times out on -# the CI simulator (slow backend round-trip). -# * interceptor / dismiss = BEST-EFFORT (non-blocking). They drive a real -# native tap/swipe on the custom-rendered paywall via idb, which is -# inherently flaky on the CI simulator. Run for signal; a failure emits a -# warning but does NOT fail the job. +# Gating model: ALL suites are HARD gates, with exactly ONE exception: the +# StoreKit restore-degradation suite (last, see bottom of this file) — non- +# gating ONLY IF every failed attempt (across all 3 retries) matches the +# known, currently-open Apple/Xcode platform bug signature +# (SKInternalErrorDomain Code=3 / "Error saving configuration file", +# FB22237318, see task-6-report.md); it then prints a loud "S7-iOS BLOCKED +# (Apple FB22237318)" marker and does NOT gate. If even ONE failed attempt +# does not match that signature (e.g. a mixed run — one attempt hits the +# Apple bug, another fails for a real regression), the suite gates normally, +# same as every other suite — a real regression must never be masked by an +# unrelated attempt's known-bug match. The previous best-effort / +# silent-::warning:: model for the idb-driven suites is forbidden by the +# mission — a suite that never fails a build is not a test. +# +# Per-attempt timeout: Flutter batches are bounded to $TIMEOUT seconds (default +# 300); StoreKit uses $STOREKIT_TIMEOUT (default 600) because its XCTest host +# has a separate 420-second bound. macOS runners do NOT ship GNU `timeout` by +# default, so this uses a portable bash watchdog (see run_with_timeout() +# below) instead of depending on `gtimeout`/coreutils being installed. The +# existing 3x retry loop applies to a timed-out attempt exactly like any +# other failure. set -uo pipefail DEV="${1:?usage: $0 }" +TIMEOUT="${TIMEOUT:-300}" # seconds per Flutter batch attempt +STOREKIT_TIMEOUT="${STOREKIT_TIMEOUT:-600}" +E2E_IOS_SUITE="${E2E_IOS_SUITE:-all}" HERE="$(cd "$(dirname "$0")" && pwd)" -EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example -cd "$EXAMPLE_DIR" +EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example +cd "$EXAMPLE_DIR" || exit 1 LOGS="integration_test/ci-logs" mkdir -p "$LOGS" flutter pub get -# Run a suite up to 3×; pass if any attempt passes. $3 = optional driver script. +# Runs "$@" with a hard $TIMEOUT-second ceiling. Portable (no dependency on +# GNU coreutils' `timeout`, absent by default on this macOS runner): +# backgrounds "$@", races it against a `sleep $TIMEOUT` watchdog, and kills +# whichever loses. Returns 124 on timeout (matches GNU timeout's convention), +# else "$@"'s own exit code. +# +# TREE KILL: "$@" is backgrounded with job control (`set -m`) enabled so bash +# gives it its own process group (PGID == its own PID) — standard POSIX job +# control, identical on GNU bash 3.2/macOS and bash 5.x/Linux (unlike +# `setsid`/GNU `timeout`, this isn't a coreutils-only feature, so it needs no +# per-runner branching). On timeout we kill the *group* +# (`kill -TERM -- "-$cmd_pid"`), not just $cmd_pid, so children reparented +# under it (dart/xcodebuild/gradle, background taps) are reached too, not +# just the immediate `flutter test` process. `set -m` is scoped to only the +# backgrounding line so it doesn't change job-control semantics anywhere +# else in this function (incl. the pipe-hang fix below). LIMITATION: a +# Gradle daemon detaches into its own session by design (so it survives its +# launching process) and therefore escapes this process group — that daemon +# is shared/pre-existing across attempts, not per-attempt state, so it isn't +# something this kill needs to reach. +# +# NOTE (found via local testing, see task-7-report.md): when this whole +# function is used as the left side of a pipe (`run_with_timeout ... | tee +# log`, exactly how it's called below), killing ONLY the watchdog subshell's +# own PID leaves its `sleep $TIMEOUT` child orphaned (reparented, not +# reaped) — and since that orphan still holds the pipe's write end open, the +# downstream `tee` never sees EOF and the whole pipeline hangs for the +# remainder of $TIMEOUT even on a perfectly healthy, fast-passing attempt. +# `pkill -P "$watchdog_pid"` (kill its child by parent-pid) BEFORE killing +# the subshell itself avoids this — verified with an isolated repro. (An +# earlier attempt to also `disown` the backgrounded "$@" job, to silence +# bash's cosmetic job-control "Terminated" notice, reintroduced this exact +# class of race on the fast/no-timeout path — dropped; the notice is +# harmless log noise, left as-is.) +run_with_timeout() { + local marker + marker="$(mktemp)" + set -m + "$@" & + local cmd_pid=$! + set +m + ( + sleep "$TIMEOUT" + if kill -0 "$cmd_pid" 2>/dev/null; then + echo "::warning::watchdog: attempt exceeded ${TIMEOUT}s, killing PGID $cmd_pid" + echo 1 >"$marker" + kill -TERM -- "-$cmd_pid" 2>/dev/null + sleep 5 + kill -KILL -- "-$cmd_pid" 2>/dev/null + fi + ) & + local watchdog_pid=$! + local status=0 + wait "$cmd_pid" 2>/dev/null || status=$? + pkill -P "$watchdog_pid" 2>/dev/null # reap the watchdog's sleep child first (see NOTE above) + kill "$watchdog_pid" 2>/dev/null + wait "$watchdog_pid" 2>/dev/null + if [ -s "$marker" ]; then + status=124 + fi + rm -f "$marker" + return "$status" +} + +# Run a suite up to 3x; pass if any attempt passes. $3 = optional driver script +# (invoked as `driver `; the per-attempt suite log path is exported as +# $SUITE_LOG for drivers that need to synchronize against it — e.g. +# interceptor_actions_driver_ios.sh polls it for a marker line instead of +# using a fixed, racy sleep between its two taps). run_suite() { local label="$1" testfile="$2" driver="$3" logbase="$4" local attempts=3 dpid="" for a in $(seq 1 "$attempts"); do - echo "=== $label (attempt $a/$attempts) ===" + echo "::group::SUITE $label attempt $a" + local start_ts end_ts duration status + start_ts=$(date +%s) dpid="" if [ -n "$driver" ]; then - bash "$HERE/$driver" "$DEV" > "$LOGS/${logbase}_driver_$a.log" 2>&1 & + export SUITE_LOG="$LOGS/${logbase}_$a.log" + bash "$HERE/$driver" "$DEV" >"$LOGS/${logbase}_driver_$a.log" 2>&1 & dpid=$! fi - if flutter test "$testfile" -d "$DEV" --reporter expanded 2>&1 | tee "$LOGS/${logbase}_$a.log"; then - cp "$LOGS/${logbase}_$a.log" "$LOGS/${logbase}.log" 2>/dev/null || true - [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true + status=0 + run_with_timeout flutter test "$testfile" -d "$DEV" --no-pub --reporter expanded 2>&1 | tee "$LOGS/${logbase}_$a.log" + status=$? + end_ts=$(date +%s) + duration=$((end_ts - start_ts)) + if [ "$status" -eq 124 ]; then + echo "SUITE $label attempt $a: exit=124 (TIMEOUT after ${TIMEOUT}s) duration=${duration}s" + else + echo "SUITE $label attempt $a: exit=$status duration=${duration}s" + fi + echo "::endgroup::" + if [ "$status" -eq 0 ]; then + if ! cp "$LOGS/${logbase}_$a.log" "$LOGS/${logbase}.log" 2>/dev/null; then + echo "[cleanup] failed to copy ${logbase}_$a.log (non-fatal)" + fi + if [ -n "$dpid" ]; then + kill "$dpid" 2>/dev/null || echo "[cleanup] driver pid $dpid already exited (non-fatal)" + fi echo "=== $label passed on attempt $a ===" return 0 fi - [ -n "$dpid" ] && kill "$dpid" 2>/dev/null || true - echo "=== $label failed attempt $a ===" - xcrun simctl terminate "$DEV" com.purchasely.demo 2>/dev/null || true + if [ -n "$dpid" ]; then + kill "$dpid" 2>/dev/null || echo "[cleanup] driver pid $dpid already exited (non-fatal)" + fi + echo "=== $label failed attempt $a (exit=$status) ===" + if ! xcrun simctl terminate "$DEV" com.purchasely.demo 2>/dev/null; then + echo "[cleanup] simctl terminate failed (non-fatal, app likely already stopped)" + fi sleep 3 done - cp "$LOGS/${logbase}_${attempts}.log" "$LOGS/${logbase}.log" 2>/dev/null || true + if ! cp "$LOGS/${logbase}_${attempts}.log" "$LOGS/${logbase}.log" 2>/dev/null; then + echo "[cleanup] failed to copy ${logbase}_${attempts}.log (non-fatal)" + fi return 1 } +if [ "$E2E_IOS_SUITE" != "all" ] && [ "$E2E_IOS_SUITE" != "storekit" ]; then + echo "::error::Unsupported E2E_IOS_SUITE=$E2E_IOS_SUITE (expected all or storekit)" + exit 2 +fi + fail=0 -echo "=== Suite 1/8: Dart↔iOS bridge (T1–T20) — HARD gate ===" -run_suite "bridge-ios" integration_test/dart_ios_bridge_test.dart "" bridge || fail=1 - -echo "=== Suite 2/8: cold-start deeplink (builder.handleDeeplink → auto-open) — HARD gate ===" -# Deterministic: the SDK opens the paywall itself from the cold-start deeplink and -# the test only asserts on analytics events (no flaky idb driver). -run_suite "deeplink-cold-start-ios" integration_test/deeplink_cold_start_test.dart "" deeplink_cold_start_ios || fail=1 - -echo "=== Suite 3/8: user-attribute listener (set/removed events) — HARD gate ===" -# Deterministic: setting/clearing an attribute makes the native SDK emit a change -# event the listener must receive (no UI interaction, no driver). -run_suite "user-attribute-listener-ios" integration_test/user_attribute_listener_test.dart "" user_attribute_listener_ios || fail=1 - -echo "=== Suite 4/8: interceptor trigger (idb tap) — best-effort ===" -run_suite "interceptor-ios" integration_test/interceptor_trigger_ios_test.dart \ - tap_purchase_ios.sh interceptor_ios \ - || echo "::warning::E2E iOS interceptor suite failed after retries (non-blocking)" - -echo "=== Suite 5/8: default dismiss handler via deeplink (idb tap close) — best-effort ===" -run_suite "dismiss-ios" integration_test/default_dismiss_handler_ios_test.dart \ - close_paywall_ios.sh dismiss_ios \ - || echo "::warning::E2E iOS dismiss suite failed after retries (non-blocking)" - -echo "=== Suite 6/8: default dismiss handler via fire-and-forget display() (idb tap close) — best-effort ===" -run_suite "dismiss-via-display-ios" integration_test/default_dismiss_via_display_ios_test.dart \ - close_paywall_ios.sh dismiss_via_display_ios \ - || echo "::warning::E2E iOS dismiss-via-display suite failed after retries (non-blocking)" - -echo "=== Suite 7/8: local dismiss handler wins over default (idb tap close) — best-effort ===" -run_suite "local-dismiss-ios" integration_test/local_dismiss_handler_ios_test.dart \ - close_paywall_ios.sh local_dismiss_ios \ - || echo "::warning::E2E iOS local-dismiss suite failed after retries (non-blocking)" - -echo "=== Suite 8/8: inline view keeps the global event stream flowing (FLT-W-12) — best-effort ===" -# No idb driver — deterministic once the inline platform view renders. This is -# the iOS-specific regression (setEventCallback clobber); kept non-blocking -# until the inline-render path is proven reliable on the CI simulator, then -# promote to a HARD gate (|| fail=1). -run_suite "inline-events-ios" integration_test/inline_events_test.dart "" inline_events_ios \ - || echo "::warning::E2E iOS inline-events suite failed after retries (non-blocking)" +if [ "$E2E_IOS_SUITE" = "all" ]; then + echo "=== Batch 1/7: core bridge/deeplink/listener/flow suites — HARD gate ===" + run_suite "core-ios" integration_test/ios_core_batch_test.dart "" core_ios || fail=1 + + echo "=== Batch 2/7: inline presentation suites — HARD gate ===" + run_suite "inline-ios" integration_test/ios_inline_batch_test.dart "" inline_ios || fail=1 + + echo "=== Batch 3/7: purchase interceptor suite — HARD gate ===" + run_suite "purchase-interceptor-ios" integration_test/interceptor_trigger_ios_test.dart \ + purchase_interceptor_driver_ios.sh purchase_interceptor_ios || fail=1 + + echo "=== Batch 4/7: navigate interceptor suites — HARD gate ===" + run_suite "navigate-interceptors-ios" integration_test/interceptor_actions_ios_test.dart \ + interceptor_actions_driver_ios.sh navigate_interceptors_ios || fail=1 + + echo "=== Batch 5/7: default/local dismiss handler suites — HARD gate ===" + run_suite "dismiss-ios" integration_test/ios_dismiss_batch_test.dart \ + dismiss_batch_driver_ios.sh dismiss_ios || fail=1 + + echo "=== Batch 6/7: modal and re-display transition regressions — HARD gate ===" + run_suite "transitions-ios" integration_test/ios_transition_batch_test.dart \ + transition_batch_driver_ios.sh transitions_ios || fail=1 +else + echo "=== Targeted manual run: skipping batches 1-6; running StoreKit only ===" +fi + +# --- Batch 7/7: S7 StoreKit restore degradation --------------------------- +# SPECIAL CASE, not run via run_suite(): purchase_restore_ios_test.dart can +# only exercise a real local StoreKit2 transaction if the app is launched +# through the Xcode scheme (Configuration.storekit is wired into the +# scheme's LaunchAction, not into plain `flutter test`'s own `xcrun simctl +# launch`) — see that file's header and task-6-report.md. That means +# `xcodebuild test` via tools/run_storekit_suite_ios.sh, not `flutter test`. +# +# KNOWN BLOCKER (task-6-report.md): as of this writing, `SKTestSession` +# reproducibly fails to start outside Xcode's own GUI Run/Test action on +# iOS/Xcode 26.5/26.6 simulators — a confirmed, currently-open Apple +# platform bug (FB22237318; matches flutter/flutter#184678, still broken on +# 26.5 per that thread). Gating a build on an external, unfixed platform bug +# would make every CI run red for a reason nobody here can act on — so, +# and ONLY when EVERY failed attempt matches this documented signature, we +# print a loud non-gating marker instead of failing the job. If any attempt +# fails for a DIFFERENT reason (wiring regression, wrong product ids, a real +# purchase/restore assertion failure, etc.) the suite gates, even if other +# attempts in the same run also matched the Apple signature — a mixed run +# must not let a real regression hide behind an unrelated known-bug match. +echo "=== Batch 7/7: S7 StoreKit restore degradation (xcodebuild, RunnerIntegrationTests) — HARD gate (Apple-bug exception) ===" +TIMEOUT="$STOREKIT_TIMEOUT" +storekit_logbase="storekit-ios" +storekit_ok=0 +storekit_apple_sig=0 +storekit_other_failure=0 +storekit_fail_count=0 +storekit_last_attempt=0 +for a in 1 2 3; do + storekit_last_attempt="$a" + echo "::group::SUITE $storekit_logbase attempt $a" + start_ts=$(date +%s) + status=0 + run_with_timeout bash "$HERE/run_storekit_suite_ios.sh" "$DEV" 2>&1 | tee "$LOGS/${storekit_logbase}_$a.log" + status=$? + end_ts=$(date +%s) + duration=$((end_ts - start_ts)) + if [ "$status" -eq 124 ]; then + echo "SUITE $storekit_logbase attempt $a: exit=124 (TIMEOUT after ${TIMEOUT}s) duration=${duration}s" + else + echo "SUITE $storekit_logbase attempt $a: exit=$status duration=${duration}s" + fi + echo "::endgroup::" + if [ "$status" -eq 0 ]; then + storekit_ok=1 + if ! cp "$LOGS/${storekit_logbase}_$a.log" "$LOGS/${storekit_logbase}.log" 2>/dev/null; then + echo "[cleanup] failed to copy ${storekit_logbase}_$a.log (non-fatal)" + fi + echo "=== $storekit_logbase passed on attempt $a ===" + break + fi + storekit_fail_count=$((storekit_fail_count + 1)) + # A Dart result marker proves the app got past SKTestSession setup, so this + # is never an Apple-only blocker — even if the verbose xcodebuild log also + # happens to contain the known signature. Explicit Dart evidence wins. + if grep -q 'S7-IOS-RESULT: FAIL' "$LOGS/${storekit_logbase}_$a.log"; then + storekit_other_failure=1 + echo "=== $storekit_logbase emitted an explicit Dart FAIL; not retrying a deterministic assertion failure ===" + break + elif grep -q 'S7-IOS-RESULT:' "$LOGS/${storekit_logbase}_$a.log"; then + storekit_other_failure=1 + elif grep -qE 'SKInternalErrorDomain Code=3|Error saving configuration file' "$LOGS/${storekit_logbase}_$a.log"; then + storekit_apple_sig=1 + else + storekit_other_failure=1 + fi + echo "=== $storekit_logbase failed attempt $a (exit=$status) ===" + if ! xcrun simctl terminate "$DEV" com.purchasely.demo 2>/dev/null; then + echo "[cleanup] simctl terminate failed (non-fatal, app likely already stopped)" + fi + sleep 3 +done + +if [ "$storekit_ok" -ne 1 ]; then + if ! cp "$LOGS/${storekit_logbase}_${storekit_last_attempt}.log" "$LOGS/${storekit_logbase}.log" 2>/dev/null; then + echo "[cleanup] failed to copy ${storekit_logbase}_${storekit_last_attempt}.log (non-fatal)" + fi + if [ "$storekit_apple_sig" -eq 1 ] && [ "$storekit_other_failure" -eq 0 ]; then + echo "################################################################" + echo "# S7-iOS BLOCKED (Apple FB22237318)" + echo "# SKInternalErrorDomain Code=3 / 'Error saving configuration file'" + echo "# — all $storekit_fail_count failed attempts matched this signature —" + echo "# confirmed, currently-open Apple/Xcode platform bug (see" + echo "# task-6-report.md). NOT gating this build." + echo "################################################################" + echo "::warning::S7-iOS BLOCKED (Apple FB22237318) — not gating (known external platform bug)" + else + echo "::error::S7-iOS (storekit) failed after retries for a reason OTHER than the known FB22237318 signature on at least one attempt — gating" + fail=1 + fi +fi echo "=== E2E iOS finished (gating fail=$fail) ===" exit $fail diff --git a/purchasely/example/integration_test/tools/dismiss_batch_driver_ios.sh b/purchasely/example/integration_test/tools/dismiss_batch_driver_ios.sh new file mode 100755 index 00000000..1260fa13 --- /dev/null +++ b/purchasely/example/integration_test/tools/dismiss_batch_driver_ios.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Host-side UI driver for ios_dismiss_batch_test.dart. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +UDID="${1:?usage: $0 }" + +for marker in DISMISS-DEFAULT-READY DISMISS-DISPLAY-READY DISMISS-LOCAL-READY; do + "$HERE/swipe_after_marker_ios.sh" "$UDID" "$marker" +done diff --git a/purchasely/example/integration_test/tools/flow_close_all.sh b/purchasely/example/integration_test/tools/flow_close_all.sh new file mode 100755 index 00000000..1019c0b9 --- /dev/null +++ b/purchasely/example/integration_test/tools/flow_close_all.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Driver wrapper for flow_dismiss_test.dart (Android): taps action:close_all +# directly from the flow's initial "calm" screen (pattern (A) in that file's +# header). The Dart suite self-recovers via Purchasely.closeAllScreens() if +# this tap isn't observed within its own timeout (see the suite's "Close:" +# section), so a single content-desc tap is sufficient CI wiring — no need +# to also drive the chained navigate-then-validate pattern (B). +# +# Thin adapter: ci_run_e2e.sh's run_suite() only ever passes the device +# serial to a driver script; tap_content_desc.sh additionally needs the +# content-desc substring to search for. +# +# Usage: flow_close_all.sh +set -uo pipefail +DEV="${1:-emulator-5554}" +HERE="$(cd "$(dirname "$0")" && pwd)" +exec bash "$HERE/tap_content_desc.sh" "$DEV" "action:close_all" diff --git a/purchasely/example/integration_test/tools/interceptor_actions_driver_ios.sh b/purchasely/example/integration_test/tools/interceptor_actions_driver_ios.sh new file mode 100755 index 00000000..d1297221 --- /dev/null +++ b/purchasely/example/integration_test/tools/interceptor_actions_driver_ios.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Driver for interceptor_actions_ios_test.dart (S5/S6): ONE real tap on +# "Login" per test, LOG-DRIVEN synchronization between them (see +# task-5-report.md: a fixed sleep is racy — S5's own post-tap cleanup +# observed 9s-24s+ variance across runs), then a re-foreground step after S6 +# backgrounds the app to Safari (otherwise tearDown's native +# interceptor-cleanup calls, and any following CI suite, risk hanging / +# bleeding into a backgrounded Safari state). +# +# Reads the suite's own per-attempt log file from $SUITE_LOG (exported by +# ci_run_e2e_ios.sh's run_suite() before backgrounding this driver — the +# very file `flutter test`'s output is concurrently being `tee`'d into) and +# polls it for the "[S5/failed] callback order:" line the Dart test prints +# once S5 resolves, before triggering S6's tap. Falls back to a fixed wait +# if $SUITE_LOG isn't set (e.g. manual invocation outside ci_run_e2e_ios.sh). +# +# Usage: interceptor_actions_driver_ios.sh +set -uo pipefail +UDID="${1:?usage: $0 }" +HERE="$(cd "$(dirname "$0")" && pwd)" + +echo "[interceptor_actions_driver_ios] tap 1/2 (S5/failed)…" +bash "$HERE/tap_after_marker_ios.sh" "$UDID" INTERCEPTOR-S5-READY 195 790 + +if [ -n "${SUITE_LOG:-}" ]; then + echo "[interceptor_actions_driver_ios] waiting for S5 callback-order marker in ${SUITE_LOG}…" + found=0 + for _ in $(seq 1 60); do + if [ -f "$SUITE_LOG" ] && grep -q '\[S5/failed\] callback order:' "$SUITE_LOG"; then + echo "[interceptor_actions_driver_ios] S5 marker observed, proceeding to tap 2/2" + found=1 + break + fi + sleep 1 + done + [ "$found" -eq 0 ] && echo "[interceptor_actions_driver_ios] S5 marker not observed after 60s, proceeding anyway" +else + echo "[interceptor_actions_driver_ios] SUITE_LOG not set — falling back to a fixed 20s wait (best-effort)" + sleep 20 +fi + +echo "[interceptor_actions_driver_ios] tap 2/2 (S6/notHandled)…" +bash "$HERE/tap_after_marker_ios.sh" "$UDID" INTERCEPTOR-S6-READY 195 790 + +echo "[interceptor_actions_driver_ios] re-foregrounding app after S6 backgrounds it to Safari…" +if ! xcrun simctl launch "$UDID" com.purchasely.demo 2>&1; then + echo "[interceptor_actions_driver_ios] re-foreground failed (non-fatal)" +fi diff --git a/purchasely/example/integration_test/tools/modal_dismissible_driver_ios.sh b/purchasely/example/integration_test/tools/modal_dismissible_driver_ios.sh new file mode 100755 index 00000000..9e281b6f --- /dev/null +++ b/purchasely/example/integration_test/tools/modal_dismissible_driver_ios.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Driver wrapper for modal_dismissible_ios_test.dart: two independent +# display() cycles (dismissible:false, then dismissible:true), each needing +# its own `swipe_dismiss_ios.sh 2` invocation — see that Dart file's +# header ("one invocation per test... chained so the second waits for the +# first to finish"). +# +# Usage: modal_dismissible_driver_ios.sh +set -euo pipefail +UDID="${1:?usage: $0 }" +HERE="$(cd "$(dirname "$0")" && pwd)" + +wait_for_suite_marker() { + local marker="$1" + if [ -z "${SUITE_LOG:-}" ]; then + echo "[modal_dismissible_driver_ios] SUITE_LOG not set; manual mode" + return 0 + fi + for _ in $(seq 1 120); do + if [ -f "$SUITE_LOG" ] && grep -q "$marker" "$SUITE_LOG"; then + echo "[modal_dismissible_driver_ios] observed $marker" + return 0 + fi + sleep 1 + done + echo "[modal_dismissible_driver_ios] missing $marker after 120s" + return 1 +} + +echo "[modal_dismissible_driver_ios] test 1/2 (dismissible:false, swipe must be a no-op)…" +wait_for_suite_marker "M1-NONDISMISSIBLE-READY" +SKIP_PAYWALL_DETECTION=1 bash "$HERE/swipe_dismiss_ios.sh" "$UDID" 2 +echo "[modal_dismissible_driver_ios] test 2/2 (dismissible:true, swipe must dismiss)…" +wait_for_suite_marker "M1-DISMISSIBLE-READY" +SKIP_PAYWALL_DETECTION=1 bash "$HERE/swipe_dismiss_ios.sh" "$UDID" 2 diff --git a/purchasely/example/integration_test/tools/press_back.sh b/purchasely/example/integration_test/tools/press_back.sh index eea63ed9..c774054f 100755 --- a/purchasely/example/integration_test/tools/press_back.sh +++ b/purchasely/example/integration_test/tools/press_back.sh @@ -32,7 +32,28 @@ dump_ui() { return 1 } +# CI-only recovery: on a resource-starved cold AVD some OTHER package (the +# launcher, in every run analyzed for task-9) can ANR mid-suite. Its "App Not +# Responding" system dialog then owns window focus for the REST of the job — +# every BACK/tap this driver sends lands on that dialog, not the app under +# test, forever (see task-9-android-report.md: identical mCurrentFocus window +# IDs across 5 different suites in one CI run). force-stop the ANR'd package +# (never our own app) so the dialog is torn down and focus returns to the +# foreground app; a no-op when nothing is stuck. +clear_stuck_anr() { + local pkg + pkg=$(adb -s "$DEV" shell dumpsys window 2>/dev/null | + grep -o 'Application Not Responding: [^}]*' | head -1 | + sed 's/Application Not Responding: //' | tr -d '\r ') + if [ -n "$pkg" ] && [ "$pkg" != "com.purchasely.demo" ]; then + echo "[press_back] $pkg is ANR'd and stealing focus, force-stopping it" + adb -s "$DEV" shell am force-stop "$pkg" 2>/dev/null + sleep 1 + fi +} + for i in $(seq 1 90); do + clear_stuck_anr if dump_ui; then if grep -q 'action:' "$DUMP_LOCAL" 2>/dev/null; then echo "[press_back] paywall detected (iter $i), pressing BACK" diff --git a/purchasely/example/integration_test/tools/purchase_interceptor_driver_ios.sh b/purchasely/example/integration_test/tools/purchase_interceptor_driver_ios.sh new file mode 100755 index 00000000..cfc693ca --- /dev/null +++ b/purchasely/example/integration_test/tools/purchase_interceptor_driver_ios.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Host-side driver for interceptor_trigger_ios_test.dart. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +UDID="${1:?usage: $0 }" + +"$HERE/tap_after_marker_ios.sh" \ + "$UDID" INTERCEPTOR-PURCHASE-READY 195 648 diff --git a/purchasely/example/integration_test/tools/re_display_driver.sh b/purchasely/example/integration_test/tools/re_display_driver.sh new file mode 100755 index 00000000..5ec2f988 --- /dev/null +++ b/purchasely/example/integration_test/tools/re_display_driver.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Driver wrapper for re_display_test.dart (Android). The suite drives TWO +# display() cycles on the SAME preloaded handle (see that file's header) and +# needs press_back.sh invoked twice, chained: each invocation waits for its +# own fresh paywall render (press_back.sh's own polling loop) before pressing +# BACK, so a straight sequential chain (no extra sleep) is safe. +# +# Usage: re_display_driver.sh +set -uo pipefail +DEV="${1:-emulator-5554}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +echo "[re_display_driver] cycle 1/2…" +bash "$HERE/press_back.sh" "$DEV" +echo "[re_display_driver] cycle 2/2…" +bash "$HERE/press_back.sh" "$DEV" diff --git a/purchasely/example/integration_test/tools/re_display_driver_ios.sh b/purchasely/example/integration_test/tools/re_display_driver_ios.sh new file mode 100755 index 00000000..a8f29d3a --- /dev/null +++ b/purchasely/example/integration_test/tools/re_display_driver_ios.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Driver wrapper for re_display_ios_test.dart. iOS mirror of +# re_display_driver.sh: the suite drives TWO display() cycles on the SAME +# preloaded handle and needs close_paywall_ios.sh invoked twice, chained +# (each invocation polls for its own fresh paywall before swiping to +# dismiss). +# +# Usage: re_display_driver_ios.sh +set -uo pipefail +UDID="${1:?usage: $0 }" +HERE="$(cd "$(dirname "$0")" && pwd)" + +"$HERE/swipe_after_marker_ios.sh" "$UDID" REDISPLAY-CYCLE-1-READY +"$HERE/swipe_after_marker_ios.sh" "$UDID" REDISPLAY-CYCLE-2-READY diff --git a/purchasely/example/integration_test/tools/run_storekit_suite_ios.sh b/purchasely/example/integration_test/tools/run_storekit_suite_ios.sh new file mode 100755 index 00000000..e1933098 --- /dev/null +++ b/purchasely/example/integration_test/tools/run_storekit_suite_ios.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Scripted runner for the S7 iOS StoreKit restore-degradation suite +# (purchase_restore_ios_test.dart / RunnerIntegrationTests). See that Dart +# file's header comment for the full execution-path rationale. +# +# RunnerIntegrationTests is a hostless XCUITest bundle (no TEST_HOST): it +# cannot propagate the Dart test's own pass/fail into xcodebuild's result; it +# only proves the app launched and the UI-test host completed. The actual +# proof is the Dart suite's debugPrint()/print() output, which reaches the +# simulator's unified log regardless of which mechanism launched the app — +# this script captures that concurrently and greps it for the outcome. +# +# GATING (Greptile P1, PR #138 review — CI run 29778281515 was a confirmed +# false green: xcodebuild exited 0 while the Dart suite had actually failed +# `expect(capturedPayload, isA())`; the old `exit $STATUS` +# below never looked at the Dart output at all). xcodebuild exit 0 is +# necessary but NOT sufficient: purchase_restore_ios_test.dart's +# `tearDownAll` prints exactly one marker line, `S7-IOS-RESULT: PASS` or +# `S7-IOS-RESULT: FAIL (completed=N/M)`, once every test in the file has run +# its body to completion. This script now exits 0 ONLY IF xcodebuild exited 0 +# AND that PASS marker landed in $FLUTTER_LOG — anything else (xcodebuild +# failure, missing marker, or an explicit FAIL marker) exits 1. +# +# Usage: bash run_storekit_suite_ios.sh +set -uo pipefail + +UDID="${1:?usage: $0 }" +HERE="$(cd "$(dirname "$0")" && pwd)" +EXAMPLE_DIR="$(cd "$HERE/../.." && pwd)" # → purchasely/example +cd "$EXAMPLE_DIR" || exit 1 + +LOGS="integration_test/ci-logs" +mkdir -p "$LOGS" +FLUTTER_LOG="$LOGS/storekit_ios_flutter.log" +: >"$FLUTTER_LOG" + +flutter pub get +flutter build ios --config-only --simulator \ + integration_test/purchase_restore_ios_test.dart +(cd ios && pod install) + +xcrun simctl terminate "$UDID" com.purchasely.demo >/dev/null 2>&1 + +# Capture the Dart suite's print()/debugPrint() lines from the simulator's +# unified log in the background — independent of xcodebuild's own result. +xcrun simctl spawn "$UDID" log stream \ + --predicate 'eventMessage CONTAINS "flutter:"' \ + >"$FLUTTER_LOG" 2>&1 & +LOG_PID=$! + +# No background UI driver here: RunnerIntegrationTests owns testmanagerd's +# automation channel while xcodebuild is active. The separate interceptor +# suite covers the real CTA; this StoreKit suite focuses on the bounded Flutter +# restore-degradation contract for a local receipt against the real backend. +# +# Flutter's in-app integration-test binding does not terminate this hostless +# launch when the Dart tests finish. Watch the authoritative Dart marker and +# terminate the app so the XCUITest host can finish immediately instead of +# waiting its full 420s hang timeout. PASS vs FAIL is still decided below. +( + while kill -0 "$LOG_PID" 2>/dev/null; do + if grep -q "S7-IOS-RESULT:" "$FLUTTER_LOG"; then + echo "[storekit marker watcher] Dart result observed; terminating app" + if ! xcrun simctl terminate "$UDID" com.purchasely.demo 2>/dev/null; then + echo "[storekit marker watcher] app already stopped (non-fatal)" + fi + exit 0 + fi + sleep 1 + done +) & +MARKER_WATCH_PID=$! + +xcodebuild test -workspace ios/Runner.xcworkspace -scheme Runner \ + -only-testing:RunnerIntegrationTests -destination "id=$UDID" +STATUS=$? + +kill "$MARKER_WATCH_PID" >/dev/null 2>&1 +wait "$MARKER_WATCH_PID" 2>/dev/null + +# Drain: `log stream` buffers internally and the Dart process's final +# tearDownAll print can race xcodebuild's own teardown — give it a few +# seconds before killing the stream so the marker line isn't lost. +sleep 5 +kill "$LOG_PID" >/dev/null 2>&1 +wait "$LOG_PID" 2>/dev/null + +# Fallback: `log stream` is a live tail and can in principle miss/truncate +# output around a fast process exit. `log show` is a point-in-time query of +# the same unified log store, not a race with the kill above — append it as +# a second, more reliable source before grepping for the marker. +if ! xcrun simctl spawn "$UDID" log show \ + --predicate 'eventMessage CONTAINS "flutter:"' --last 5m \ + >>"$FLUTTER_LOG" 2>&1; then + echo "[cleanup] simulator log fallback failed (non-fatal)" +fi + +echo "=== Dart suite output ($FLUTTER_LOG, last 40 lines) ===" +tail -n 40 "$FLUTTER_LOG" 2>/dev/null || echo "(log file empty/unreadable)" + +if [ "$STATUS" -eq 0 ] && grep -q "S7-IOS-RESULT: PASS" "$FLUTTER_LOG"; then + echo "storekit-ios: PASS (xcodebuild exit=0, Dart marker=S7-IOS-RESULT: PASS)" + exit 0 +fi + +echo "################################################################" +echo "# storekit-ios FAILED — xcodebuild exit=$STATUS" +if grep -q "S7-IOS-RESULT:" "$FLUTTER_LOG"; then + echo "# Dart marker: $(grep "S7-IOS-RESULT:" "$FLUTTER_LOG" | tail -1)" +else + echo "# Dart marker: MISSING — the Dart suite likely never reached" + echo "# tearDownAll (crash or hang; RunnerIntegrationTests.m XCTFails" + echo "# on its own 420s poll timeout instead of exiting 0 silently)." +fi +echo "################################################################" +exit 1 diff --git a/purchasely/example/integration_test/tools/swipe_after_marker_ios.sh b/purchasely/example/integration_test/tools/swipe_after_marker_ios.sh new file mode 100755 index 00000000..a59b391e --- /dev/null +++ b/purchasely/example/integration_test/tools/swipe_after_marker_ios.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Waits for a Dart readiness marker, then sends a deterministic swipe-down. +set -euo pipefail + +UDID="${1:?usage: $0 }" +MARKER="${2:?usage: $0 }" +HERE="$(cd "$(dirname "$0")" && pwd)" +SUITE_LOG="${SUITE_LOG:?SUITE_LOG must point to the active Flutter test log}" + +for _ in $(seq 1 120); do + if [ -f "$SUITE_LOG" ] && grep -q "$MARKER" "$SUITE_LOG"; then + echo "[swipe_after_marker_ios] observed $MARKER" + SKIP_PAYWALL_DETECTION=1 bash "$HERE/swipe_dismiss_ios.sh" "$UDID" 2 + exit $? + fi + sleep 1 +done + +echo "[swipe_after_marker_ios] missing $MARKER after 120s" +exit 1 diff --git a/purchasely/example/integration_test/tools/swipe_dismiss_ios.sh b/purchasely/example/integration_test/tools/swipe_dismiss_ios.sh new file mode 100755 index 00000000..7b835a1f --- /dev/null +++ b/purchasely/example/integration_test/tools/swipe_dismiss_ios.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# Host-side UI driver: interactive swipe-dismiss for iOS modal paywalls. +# +# Factored from close_paywall_ios.sh. That script keeps swiping until the +# paywall is gone (best-effort "make it close" driver). This one instead +# sends an EXACT number of swipe-down gestures and exits 0 once they've been +# emitted — it deliberately does NOT assert whether the paywall actually +# closed. Whether a swipe should have dismissed the paywall (dismissible +# modal) or been ignored (non-dismissible modal, PR #136 regression guard) is +# the Dart test's call, not this script's. +# +# Prints `PAYWALL_PRESENT=true|false` after the swipes (re-checking the same +# markers) so the CI log stays diagnosticable even though this script itself +# doesn't assert on it. +# +# Uses `idb` (pip install fb-idb) + idb-companion (brew install idb-companion). +# A wrapper sets up an asyncio event loop before idb's main(), fixing Python +# 3.12+. The AX JSON is passed to the parser via an env var (NOT stdin), since +# `python3 - < [n_swipes] +# n_swipes defaults to 2. +# MAX_WAIT_SECONDS controls the pre-swipe paywall poll (default 60). +# SKIP_PAYWALL_DETECTION=1 sends the gesture using 390x852 geometry; use it +# only after a Dart readiness marker proves the presentation is visible. +# +# Run concurrently with the test: +# bash integration_test/tools/swipe_dismiss_ios.sh 2 & +# flutter test integration_test/modal_dismissible_ios_test.dart -d +# +# Exits 0 once n_swipes gesture(s) have been sent, 1 if the paywall never +# appeared within the poll window. +set -uo pipefail + +UDID="${1:?usage: $0 [n_swipes]}" +N_SWIPES="${2:-2}" +MAX_WAIT_SECONDS="${MAX_WAIT_SECONDS:-60}" +# Labels that prove a Purchasely paywall is on screen (locale-independent +# marker first). +PAYWALL_MARKERS="Powered by Purchasely|Restore purchase|Continue" + +run_idb() { + python3 - "$@" <<'__PYEOF__' +import asyncio, sys +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +from idb.cli.main import main +sys.exit(main()) +__PYEOF__ +} + +paywall_geometry() { + # Prints "W H" (screen size) if a paywall marker is present, else nothing. + local raw + raw=$(run_idb ui describe-all --json --udid "$UDID" 2>/dev/null) || return 1 + AXJSON="$raw" MARKERS="$PAYWALL_MARKERS" python3 <<'PY' +import os, json +markers = [m.strip().lower() for m in os.environ["MARKERS"].split("|")] +try: + data = json.loads(os.environ["AXJSON"]) +except Exception: + raise SystemExit(0) +labels = [(el.get("AXLabel") or "").strip().lower() for el in data] +if any(m in labels for m in markers): + # The application element carries the full-screen frame. + for el in data: + if el.get("type") == "Application": + f = el.get("frame", {}) + print(f"{int(round(f.get('width', 390)))} {int(round(f.get('height', 844)))}") + break + else: + # Fallback: iPhone-15-class default is safer than iPhone-SE-era 390x844 + # across modern simulators (Pro Max is 430x932; 390x852 is a safe mid-point). + print("390 852") +PY +} + +paywall_present() { + [ -n "$(paywall_geometry)" ] +} + +geom="" +if [ "${SKIP_PAYWALL_DETECTION:-0}" = "1" ]; then + geom="390 852" + echo "[swipe_dismiss_ios] Dart readiness marker observed; using ${geom} gesture geometry" +else + # Wait for the paywall to appear before swiping. + for i in $(seq 1 "$MAX_WAIT_SECONDS"); do + geom=$(paywall_geometry) + if [ -n "$geom" ]; then + break + fi + echo "[swipe_dismiss_ios] paywall not detected yet (iter $i/$MAX_WAIT_SECONDS), retrying…" + sleep 1 + done +fi + +if [ -z "$geom" ]; then + echo "[swipe_dismiss_ios] paywall not detected after $MAX_WAIT_SECONDS s" + echo "PAYWALL_PRESENT=false" + exit 1 +fi + +w=$(echo "$geom" | awk '{print $1}') +h=$(echo "$geom" | awk '{print $2}') +cx=$((w / 2)) +y_start=$((h / 5)) +y_end=$((h - 20)) + +echo "[swipe_dismiss_ios] paywall detected (${w}x${h}); sending $N_SWIPES swipe-down gesture(s) ($cx,$y_start)->($cx,$y_end)…" +sleep 1 +for n in $(seq 1 "$N_SWIPES"); do + run_idb ui swipe "$cx" "$y_start" "$cx" "$y_end" --duration 0.25 --udid "$UDID" 2>&1 + echo "[swipe_dismiss_ios] swipe $n/$N_SWIPES sent ✓" + sleep 2 +done + +if paywall_present; then + echo "PAYWALL_PRESENT=true" +else + echo "PAYWALL_PRESENT=false" +fi + +exit 0 diff --git a/purchasely/example/integration_test/tools/tap_after_marker_ios.sh b/purchasely/example/integration_test/tools/tap_after_marker_ios.sh new file mode 100755 index 00000000..23fe69d0 --- /dev/null +++ b/purchasely/example/integration_test/tools/tap_after_marker_ios.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Waits for a Dart readiness marker, then taps deterministic screen coordinates. +set -euo pipefail + +UDID="${1:?usage: $0 [tap-count]}" +MARKER="${2:?usage: $0 [tap-count]}" +X="${3:?usage: $0 [tap-count]}" +Y="${4:?usage: $0 [tap-count]}" +TAP_COUNT="${5:-1}" +SUITE_LOG="${SUITE_LOG:?SUITE_LOG must point to the active Flutter test log}" + +run_idb() { + python3 - "$@" <<'__PYEOF__' +import asyncio, sys +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +from idb.cli.main import main +sys.exit(main()) +__PYEOF__ +} + +app_geometry() { + local screenshot="/tmp/tap_after_marker_ios_$$.png" + local pixel_width pixel_height scale width height + if ! xcrun simctl io "$UDID" screenshot "$screenshot" >/dev/null 2>&1; then + return 1 + fi + pixel_width=$(sips -g pixelWidth "$screenshot" 2>/dev/null | awk '/pixelWidth:/ {print $2}') + pixel_height=$(sips -g pixelHeight "$screenshot" 2>/dev/null | awk '/pixelHeight:/ {print $2}') + rm -f "$screenshot" + + for scale in 3 2; do + width=$((pixel_width / scale)) + height=$((pixel_height / scale)) + if [ "$width" -ge 320 ] && [ "$width" -le 500 ] && + [ "$height" -ge 568 ] && [ "$height" -le 1100 ]; then + echo "$width $height" + return 0 + fi + done + return 1 +} + +for _ in $(seq 1 120); do + if [ -f "$SUITE_LOG" ] && grep -q "$MARKER" "$SUITE_LOG"; then + geom=$(app_geometry) + if [ -n "$geom" ]; then + width=$(echo "$geom" | awk '{print $1}') + height=$(echo "$geom" | awk '{print $2}') + else + width=390 + height=852 + fi + scaled_x=$((X * width / 390)) + scaled_y=$((Y * height / 852)) + echo "[tap_after_marker_ios] observed $MARKER; app=${width}x${height}, tapping ($scaled_x,$scaled_y) $TAP_COUNT time(s)" + for n in $(seq 1 "$TAP_COUNT"); do + run_idb ui tap "$scaled_x" "$scaled_y" --udid "$UDID" 2>&1 + echo "[tap_after_marker_ios] tap $n/$TAP_COUNT sent ✓" + sleep 1 + done + exit 0 + fi + sleep 1 +done + +echo "[tap_after_marker_ios] missing $MARKER after 120s" +exit 1 diff --git a/purchasely/example/integration_test/tools/tap_content_desc.sh b/purchasely/example/integration_test/tools/tap_content_desc.sh new file mode 100755 index 00000000..27ef8563 --- /dev/null +++ b/purchasely/example/integration_test/tools/tap_content_desc.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# Host-side UI driver: taps the first node whose content-desc contains a +# given substring. Generalization of tap_purchase.sh (hardcoded to +# "action:purchase") for flow_dismiss_test.dart, which needs to tap several +# different Purchasely flow controls (an option item, action:validate_options, +# action:open_flow_step, action:close_all) by content-desc substring. +# +# Usage: tap_content_desc.sh +# +# Run one or more concurrently with the test, chained with `;` so each waits +# for the previous tap before looking for the next control: +# (bash integration_test/tools/tap_content_desc.sh emulator-5554 "Develop Gratitude" ; \ +# bash integration_test/tools/tap_content_desc.sh emulator-5554 "action:validate_options" ; \ +# bash integration_test/tools/tap_content_desc.sh emulator-5554 "action:close_all") & +# flutter test integration_test/flow_dismiss_test.dart -d emulator-5554 +# +# Verbose per-iteration logging + dump retry: `uiautomator dump` can transiently +# fail with "could not get idle state" while the paywall is still animating / +# loading (more common on the slow CI emulator), so we retry the dump and log +# every iteration's outcome to survive being killed when the test ends. +# +# Exits 0 after a successful tap, 1 on timeout. +set -uo pipefail + +DEV="${1:-emulator-5554}" +DESC="${2:?usage: $0 }" +DUMP_DEV="/sdcard/uidump_tap_content_desc.xml" +DUMP_LOCAL="/tmp/uidump_tap_content_desc_${DEV//[^a-zA-Z0-9]/_}.xml" + +dump_ui() { + local out + for _ in 1 2 3; do + out=$(adb -s "$DEV" exec-out uiautomator dump "$DUMP_DEV" 2>&1) + if echo "$out" | grep -q "dumped to"; then + adb -s "$DEV" pull "$DUMP_DEV" "$DUMP_LOCAL" >/dev/null 2>&1 && return 0 + fi + sleep 1 + done + echo " dump failed: $out" + return 1 +} + +# CI-only recovery: on a resource-starved cold AVD some OTHER package (the +# launcher, in every run analyzed for task-9) can ANR mid-suite. Its "App Not +# Responding" system dialog then owns window focus for the REST of the job — +# every BACK/tap this driver sends lands on that dialog, not the app under +# test, forever (see task-9-android-report.md: identical mCurrentFocus window +# IDs across 5 different suites in one CI run). force-stop the ANR'd package +# (never our own app) so the dialog is torn down and focus returns to the +# foreground app; a no-op when nothing is stuck. +clear_stuck_anr() { + local pkg + pkg=$(adb -s "$DEV" shell dumpsys window 2>/dev/null | + grep -o 'Application Not Responding: [^}]*' | head -1 | + sed 's/Application Not Responding: //' | tr -d '\r ') + if [ -n "$pkg" ] && [ "$pkg" != "com.purchasely.demo" ]; then + echo "[tap_content_desc] $pkg is ANR'd and stealing focus, force-stopping it" + adb -s "$DEV" shell am force-stop "$pkg" 2>/dev/null + sleep 1 + fi +} + +for i in $(seq 1 90); do + clear_stuck_anr + if dump_ui; then + coords=$(python3 - "$DESC" "$DUMP_LOCAL" <<'PY' +import sys, re +desc, path = sys.argv[1], sys.argv[2] +try: + xml = open(path, encoding='utf-8').read() +except Exception: + sys.exit(0) +for m in re.finditer(r']*>', xml): + tag = m.group(0) + cd = re.search(r'content-desc="([^"]*)"', tag) + if cd and desc in cd.group(1): + b = re.search(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', tag) + if b: + x1, y1, x2, y2 = map(int, b.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + break +PY +) + if [ -n "$coords" ]; then + echo "[tap_content_desc] found '$DESC' at $coords (iter $i), tapping…" + # coords is emitted as two validated integers by the parser above. + # shellcheck disable=SC2086 + adb -s "$DEV" shell input tap $coords + echo "[tap_content_desc] tapped ✓" + exit 0 + else + n=$(grep -c '/dev/null || echo 0) + echo "[tap_content_desc] iter $i: dump ok ($n nodes), no '$DESC' yet" + if [ "$i" = "5" ]; then + echo " [diag] focus: $(adb -s "$DEV" shell dumpsys window 2>/dev/null | grep -E 'mCurrentFocus|mFocusedApp' | tr -d '\r')" + echo " [diag] dump head: $(head -c 1200 "$DUMP_LOCAL" 2>/dev/null | tr -d '\n')" + fi + fi + else + echo "[tap_content_desc] iter $i: dump unavailable, retrying" + fi + sleep 1 +done +echo "[tap_content_desc] node containing '$DESC' not found after polling" +exit 1 diff --git a/purchasely/example/integration_test/tools/tap_label_ios.sh b/purchasely/example/integration_test/tools/tap_label_ios.sh new file mode 100755 index 00000000..f5eb5d85 --- /dev/null +++ b/purchasely/example/integration_test/tools/tap_label_ios.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Host-side UI driver: taps the first element whose AXLabel exactly matches +# (case-insensitive) one of a pipe-separated list of labels. Generalization +# of tap_purchase_ios.sh (hardcoded to the purchase CTA's labels) for +# flow_dismiss_ios_test.dart and any other suite that needs to tap a +# specific iOS control by its (runtime-discovered) accessibility label. +# +# The Purchasely iOS paywall/flow screens are custom-rendered: their +# accessibility tree exposes only StaticText elements (AXLabel + frame), not +# interactive elements with accessibility identifiers — so we locate a +# control by its visible label and tap the centre of its frame (the label +# overlays the control). +# +# Uses `idb` (pip install fb-idb) + idb-companion (brew install idb-companion). +# `ui describe-all --json` returns a FLAT array; a wrapper sets up an asyncio +# event loop before idb's main() runs, fixing the RuntimeError on Python 3.12+. +# +# Usage: tap_label_ios.sh [max-taps] +# max-taps defaults to 1 (tap once and exit 0). Pass a higher number for +# controls where a single tap occasionally doesn't register (mirrors +# tap_purchase_ios.sh's retry behavior). +# +# Run concurrently with the test: +# bash integration_test/tools/tap_label_ios.sh "Close|Fermer" & +# flutter test integration_test/flow_dismiss_ios_test.dart -d +# +# Exits 0 after (at least one) successful tap, 1 on timeout. +set -uo pipefail + +UDID="${1:?usage: $0 [max-taps]}" +LABELS="${2:?usage: $0 [max-taps]}" +MAX_TAPS="${3:-1}" + +run_idb() { + python3 - "$@" <<'__PYEOF__' +import asyncio, sys +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +from idb.cli.main import main +sys.exit(main()) +__PYEOF__ +} + +find_and_tap() { + local raw + raw=$(run_idb ui describe-all --json --udid "$UDID" 2>/dev/null) || return 1 + + # Pass the JSON via an env var, NOT stdin: `python3 - <&1 + echo "[tap_label_ios] tapped ✓" + return 0 +} + +taps=0 +for i in $(seq 1 90); do + if find_and_tap; then + taps=$((taps + 1)) + [ "$taps" -ge "$MAX_TAPS" ] && exit 0 + sleep 2 + else + echo "[tap_label_ios] label ($LABELS) not found yet (iter $i/90), retrying…" + sleep 1 + fi +done + +if [ "$taps" -gt 0 ]; then exit 0; fi +echo "[tap_label_ios] label ($LABELS) not found after 90 s" +exit 1 diff --git a/purchasely/example/integration_test/tools/tap_purchase.sh b/purchasely/example/integration_test/tools/tap_purchase.sh index 2ca83d22..84fc6f22 100755 --- a/purchasely/example/integration_test/tools/tap_purchase.sh +++ b/purchasely/example/integration_test/tools/tap_purchase.sh @@ -32,7 +32,28 @@ dump_ui() { return 1 } +# CI-only recovery: on a resource-starved cold AVD some OTHER package (the +# launcher, in every run analyzed for task-9) can ANR mid-suite. Its "App Not +# Responding" system dialog then owns window focus for the REST of the job — +# every BACK/tap this driver sends lands on that dialog, not the app under +# test, forever (see task-9-android-report.md: identical mCurrentFocus window +# IDs across 5 different suites in one CI run). force-stop the ANR'd package +# (never our own app) so the dialog is torn down and focus returns to the +# foreground app; a no-op when nothing is stuck. +clear_stuck_anr() { + local pkg + pkg=$(adb -s "$DEV" shell dumpsys window 2>/dev/null | + grep -o 'Application Not Responding: [^}]*' | head -1 | + sed 's/Application Not Responding: //' | tr -d '\r ') + if [ -n "$pkg" ] && [ "$pkg" != "com.purchasely.demo" ]; then + echo "[tap_purchase] $pkg is ANR'd and stealing focus, force-stopping it" + adb -s "$DEV" shell am force-stop "$pkg" 2>/dev/null + sleep 1 + fi +} + for i in $(seq 1 90); do + clear_stuck_anr if dump_ui; then coords=$(python3 - "$DESC" "$DUMP_LOCAL" <<'PY' import sys, re @@ -54,6 +75,8 @@ PY ) if [ -n "$coords" ]; then echo "[tap_purchase] found '$DESC' at $coords (iter $i), tapping…" + # coords is emitted as two validated integers by the parser above. + # shellcheck disable=SC2086 adb -s "$DEV" shell input tap $coords echo "[tap_purchase] tapped ✓" exit 0 diff --git a/purchasely/example/integration_test/tools/tap_purchase_ios.sh b/purchasely/example/integration_test/tools/tap_purchase_ios.sh index 81ed2824..be12c943 100755 --- a/purchasely/example/integration_test/tools/tap_purchase_ios.sh +++ b/purchasely/example/integration_test/tools/tap_purchase_ios.sh @@ -83,6 +83,10 @@ for i in $(seq 1 90); do [ "$taps" -ge 8 ] && exit 0 sleep 2 else + if [ "$taps" -gt 0 ]; then + echo "[tap_purchase_ios] purchase CTA disappeared after $taps tap(s) — interceptor handled it ✓" + exit 0 + fi echo "[tap_purchase_ios] purchase CTA not found yet (iter $i/90), retrying…" sleep 1 fi diff --git a/purchasely/example/integration_test/tools/transition_batch_driver_ios.sh b/purchasely/example/integration_test/tools/transition_batch_driver_ios.sh new file mode 100755 index 00000000..ca3ad3b3 --- /dev/null +++ b/purchasely/example/integration_test/tools/transition_batch_driver_ios.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Host-side UI driver for ios_transition_batch_test.dart. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +UDID="${1:?usage: $0 }" + +"$HERE/modal_dismissible_driver_ios.sh" "$UDID" +"$HERE/re_display_driver_ios.sh" "$UDID" diff --git a/purchasely/example/integration_test/user_attribute_listener_test.dart b/purchasely/example/integration_test/user_attribute_listener_test.dart index 927350e1..b77052d1 100644 --- a/purchasely/example/integration_test/user_attribute_listener_test.dart +++ b/purchasely/example/integration_test/user_attribute_listener_test.dart @@ -12,6 +12,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 kAttrKey = 'e2e_listener_attr'; @@ -19,10 +21,10 @@ 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'); }); diff --git a/purchasely/example/ios/Configuration.storekit b/purchasely/example/ios/Configuration.storekit new file mode 100644 index 00000000..0566685e --- /dev/null +++ b/purchasely/example/ios/Configuration.storekit @@ -0,0 +1,86 @@ +{ + "identifier" : "D6E4A012", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + "_locale" : "en_US", + "_storefront" : "USA" + }, + "subscriptionGroups" : [ + { + "id" : "9A6BE001", + "localizations" : [ + + ], + "name" : "Purchasely Plus", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "4.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "9A6BE0A0", + "introductoryOffers" : [ + + ], + "localizations" : [ + { + "description" : "Monthly access to Purchasely Plus", + "displayName" : "Plus Monthly", + "locale" : "en_US" + } + ], + "productID" : "com.purchasely.plus.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Plus Monthly", + "subscriptionGroupID" : "9A6BE001", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "39.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "9A6BE0B0", + "introductoryOffers" : [ + + ], + "localizations" : [ + { + "description" : "Yearly access to Purchasely Plus", + "displayName" : "Plus Yearly", + "locale" : "en_US" + } + ], + "productID" : "com.purchasely.plus.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Plus Yearly", + "subscriptionGroupID" : "9A6BE001", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/purchasely/example/ios/Runner.xcodeproj/project.pbxproj b/purchasely/example/ios/Runner.xcodeproj/project.pbxproj index 74c8384f..6a3cd3dd 100644 --- a/purchasely/example/ios/Runner.xcodeproj/project.pbxproj +++ b/purchasely/example/ios/Runner.xcodeproj/project.pbxproj @@ -16,10 +16,20 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; AABBCC001122334455667788 /* SwiftPurchaselyFlutterPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC001122334455667789 /* SwiftPurchaselyFlutterPluginTests.swift */; }; + ABF77F9B5082B47049BB1F32 /* RunnerIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FF8D997DD97D40D8691C163C /* RunnerIntegrationTests.m */; }; BECE0539203CAD00099DB310 /* Pods_Runner_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CAB1C303950201B918CCAF33 /* Pods_Runner_RunnerTests.framework */; }; + D949FD39D365D2883B999D99 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 49129B8100157C9CE3068C0B /* Foundation.framework */; }; + E746A57AD8DA3AF188E0B647 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1F26DD52F88DFD3F22E678ED /* Configuration.storekit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 8AE99A725F83752E4E8F6C99 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; AABBCC00112233445566778C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -46,8 +56,11 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 17B22715BD3A135D76BC7304 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1F26DD52F88DFD3F22E678ED /* Configuration.storekit */ = {isa = PBXFileReference; includeInIndex = 1; name = Configuration.storekit; path = Configuration.storekit; sourceTree = ""; }; 272DE172C90F9A96BE06C52F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 44B8B5E6E804D048DC5A41C6 /* RunnerIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 49129B8100157C9CE3068C0B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 4E7DFFB5283CC2C800316AFB /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 4EC44EC0283BE7AB008BD15B /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 4F4E49DA4370D070299CD6C7 /* Pods-Runner-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerTests/Pods-Runner-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -71,10 +84,20 @@ AABBCC001122334455667790 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; AABBCC001122334455667791 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; CAB1C303950201B918CCAF33 /* Pods_Runner_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EBE25A83CE130B5A73FB7AC0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EC0FBE440D4C229EAB98A9F3 /* Pods-Runner-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerTests/Pods-Runner-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + FF8D997DD97D40D8691C163C /* RunnerIntegrationTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = RunnerIntegrationTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 5AE7F7999063F2AD22166A09 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D949FD39D365D2883B999D99 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -95,6 +118,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 55C1AA598375A780C1D39FC6 /* RunnerIntegrationTests */ = { + isa = PBXGroup; + children = ( + EBE25A83CE130B5A73FB7AC0 /* Info.plist */, + FF8D997DD97D40D8691C163C /* RunnerIntegrationTests.m */, + ); + name = RunnerIntegrationTests; + path = RunnerIntegrationTests; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -115,6 +148,8 @@ 97C146EF1CF9000F007C117D /* Products */, BFC73E7C5F622EBD7F5E86BE /* Pods */, FF00D034BC4D1286CB0567E4 /* Frameworks */, + 55C1AA598375A780C1D39FC6 /* RunnerIntegrationTests */, + 1F26DD52F88DFD3F22E678ED /* Configuration.storekit */, ); sourceTree = ""; }; @@ -123,6 +158,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, AABBCC00112233445566778D /* RunnerTests.xctest */, + 44B8B5E6E804D048DC5A41C6 /* RunnerIntegrationTests.xctest */, ); name = Products; sourceTree = ""; @@ -168,12 +204,21 @@ path = Pods; sourceTree = ""; }; + C956AB400DF3557936B6A05B /* iOS */ = { + isa = PBXGroup; + children = ( + 49129B8100157C9CE3068C0B /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; FF00D034BC4D1286CB0567E4 /* Frameworks */ = { isa = PBXGroup; children = ( 4E7DFFB5283CC2C800316AFB /* StoreKit.framework */, 272DE172C90F9A96BE06C52F /* Pods_Runner.framework */, CAB1C303950201B918CCAF33 /* Pods_Runner_RunnerTests.framework */, + C956AB400DF3557936B6A05B /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -225,6 +270,24 @@ productReference = AABBCC00112233445566778D /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + B653AB65E064D6890253F95E /* RunnerIntegrationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A322955D022F241A1AA8B24 /* Build configuration list for PBXNativeTarget "RunnerIntegrationTests" */; + buildPhases = ( + 2FE5E3C5F5C1C0016B600FD5 /* Sources */, + 5AE7F7999063F2AD22166A09 /* Frameworks */, + 0AE5900F1E574B62C5547296 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BC1475631571CA679D5F509D /* PBXTargetDependency */, + ); + name = RunnerIntegrationTests; + productName = RunnerIntegrationTests; + productReference = 44B8B5E6E804D048DC5A41C6 /* RunnerIntegrationTests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -241,6 +304,11 @@ AABBCC001122334455667794 = { CreatedOnToolsVersion = 14.0; }; + B653AB65E064D6890253F95E = { + CreatedOnToolsVersion = 14.0; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -258,11 +326,20 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, AABBCC001122334455667794 /* RunnerTests */, + B653AB65E064D6890253F95E /* RunnerIntegrationTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 0AE5900F1E574B62C5547296 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E746A57AD8DA3AF188E0B647 /* Configuration.storekit in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -429,6 +506,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 2FE5E3C5F5C1C0016B600FD5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ABF77F9B5082B47049BB1F32 /* RunnerIntegrationTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -454,6 +539,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = AABBCC00112233445566778C /* PBXContainerItemProxy */; }; + BC1475631571CA679D5F509D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Runner; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 8AE99A725F83752E4E8F6C99 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -706,6 +797,31 @@ }; name = Release; }; + A129E73CDE6F5350D69090A8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XL327LBYNK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RunnerIntegrationTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.purchasely.demo.RunnerIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; AABBCC00112233445566779A /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 4F4E49DA4370D070299CD6C7 /* Pods-Runner-RunnerTests.debug.xcconfig */; @@ -782,9 +898,71 @@ }; name = Profile; }; + E616B0577AFFF3AF69CE4F13 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XL327LBYNK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RunnerIntegrationTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.purchasely.demo.RunnerIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + EB495361B67640C598C2A430 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XL327LBYNK; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RunnerIntegrationTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.purchasely.demo.RunnerIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 1A322955D022F241A1AA8B24 /* Build configuration list for PBXNativeTarget "RunnerIntegrationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EB495361B67640C598C2A430 /* Release */, + A129E73CDE6F5350D69090A8 /* Debug */, + E616B0577AFFF3AF69CE4F13 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/purchasely/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/purchasely/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 6f4a47ab..37e5d8ee 100644 --- a/purchasely/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/purchasely/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -66,6 +66,16 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + + + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/purchasely/example/ios/RunnerIntegrationTests/RunnerIntegrationTests.m b/purchasely/example/ios/RunnerIntegrationTests/RunnerIntegrationTests.m new file mode 100644 index 00000000..2d678cdd --- /dev/null +++ b/purchasely/example/ios/RunnerIntegrationTests/RunnerIntegrationTests.m @@ -0,0 +1,105 @@ +// Host for the S7 iOS StoreKit restore-degradation suite +// (purchase_restore_ios_test.dart) — see that file's header comment for the +// full investigation writeup. Short version: +// +// StoreKit Testing configuration (Configuration.storekit) wired into the +// scheme's LaunchAction (Apple's own scheme editor calls this concept +// `IDEStoreKitLaunchActionOptionViewController` — a GUI/Debug convenience) +// is NOT honored by `xcodebuild test` run from the command line — confirmed +// empirically across three attempts (hosted unit test + LaunchAction-only + +// LaunchAction-and-TestAction identifiers): every run hit REAL StoreKit +// sandbox auth (`storekitd: ... Sandbox ... No account for TransactionQuery`) +// and the system "Sign in to your Apple Account" sheet, never local test +// products. The scheme wiring only takes effect via Xcode's own GUI Run/Test +// action (a private XPC connection Xcode.app itself establishes) — it is a +// documented gap for CLI-only CI. +// +// The CI-compatible mechanism is Apple's public `StoreKitTest` framework: +// create an `SKTestSession` programmatically, from INSIDE the test process, +// before launching the app under test. This does not depend on Xcode's GUI +// at all and is exactly what this target does in `-setUp`. The scheme's +// LaunchAction StoreKitConfigurationFileReference stays wired too (harmless, +// matches the brief's literal instruction, and covers the interactive +// Xcode GUI path for anyone debugging this suite by hand). +// +// This is also a genuine UI Testing Bundle (`XCUIApplication().launch()`), +// not the Flutter-official `TEST_HOST`-hosted Unit Testing Bundle pattern +// (`integration_test`'s own README / Firebase Test Lab recipe) — that +// pattern launches the host app via XCTest injecting into the binary +// directly, bypassing the app-launch path a `SKTestSession` needs to +// intercept. Trade-off: this target loses Flutter's in-process pass/fail +// propagation (the `INTEGRATION_TEST_IOS_RUNNER` macro needs to share a +// process with the Flutter engine); this XCTest itself only proves the app +// launched and eventually exited/timed out. The real evidence is the Dart +// suite's own debugPrint()/print() output — it reaches this same process's +// stdout regardless of which mechanism launched it, and lands in the +// simulator's unified log (`Runner: (Flutter) flutter: ...`), NOT directly +// in xcodebuild's own captured console for a UI-tested app-under-test. +// Capture it with (see tools/run_storekit_suite_ios.sh): +// xcrun simctl spawn log stream --predicate 'eventMessage CONTAINS "flutter:"' +// +// Run: +// flutter build ios --config-only --simulator \ +// integration_test/purchase_restore_ios_test.dart +// xcodebuild test -workspace Runner.xcworkspace -scheme Runner \ +// -only-testing:RunnerIntegrationTests -destination id= +#import +@import StoreKitTest; + +@interface RunnerIntegrationTests : XCTestCase +@property(nonatomic, strong) SKTestSession *storeKitSession; +@end + +@implementation RunnerIntegrationTests + +- (void)setUp { + [super setUp]; + self.continueAfterFailure = NO; + + NSError *error = nil; + self.storeKitSession = + [[SKTestSession alloc] initWithConfigurationFileNamed:@"Configuration" + error:&error]; + if (error != nil) { + XCTFail(@"Failed to start SKTestSession from Configuration.storekit: %@", + error); + return; + } + // resetToDefaultState also resets disableDialogs to NO, so reset and clear + // before enabling unattended purchases. Doing this in the opposite order + // leaves the SDK waiting forever on StoreKit's confirmation sheet in CI. + [self.storeKitSession resetToDefaultState]; + [self.storeKitSession clearTransactions]; + + // Auto-confirm the purchase (no system confirmation sheet): the test is + // proving the SDK's restore flow, not Apple's own confirmation UI, + // and that sheet lives outside the app process (SpringBoard), which idb's + // app-scoped `ui describe-all` cannot reliably reach. This is exactly what + // disableDialogs exists for — unattended StoreKit testing. + self.storeKitSession.disableDialogs = YES; +} + +- (void)tearDown { + [self.storeKitSession clearTransactions]; + self.storeKitSession = nil; + [super tearDown]; +} + +- (void)testS7StorekitPurchaseRestoreEntrypointRuns { + XCUIApplication *app = [[XCUIApplication alloc] init]; + [app launch]; + + // tools/run_storekit_suite_ios.sh watches the Dart PASS/FAIL marker and + // terminates the app as soon as the Dart suite finishes. Poll for that + // bounded termination. If no marker is ever emitted (setup crash/hang), + // retain this independent timeout so xcodebuild cannot false-green. + BOOL didStop = [app waitForState:XCUIApplicationStateNotRunning + timeout:420.0]; + if (!didStop) { + XCTFail(@"App did not exit within the 420s wait window — the Dart suite " + @"never emitted a result marker or the marker watcher could not " + @"terminate it. Check storekit_ios_flutter.log."); + } +} + +@end