diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index be8bb98..c44319b 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -43,3 +43,39 @@ jobs: - name: Publish dry run run: dart pub publish --dry-run + + integration-smoke: + name: Android Integration Smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Install Flutter + run: | + curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" -o "$RUNNER_TEMP/flutter.tar.xz" + tar -xf "$RUNNER_TEMP/flutter.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/flutter/bin" >> $GITHUB_PATH + + - name: Install package dependencies + run: flutter pub get + + - name: Install example dependencies + working-directory: example + run: flutter pub get + + - name: Run Android integration smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + profile: pixel_6 + script: | + cd example + flutter test integration_test/app_smoke_test.dart -d emulator-5554 diff --git a/AGENTS.md b/AGENTS.md index da03512..ab747aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ These instructions apply to the whole repository. - Prefer these checks after code changes: - `flutter analyze --no-fatal-infos` - `flutter test` +- For runtime regression work, prefer the example smoke test: + - `cd example && flutter test integration_test/app_smoke_test.dart -d ` - If you only changed package Dart code, still consider analyzer impact on `example/`. - If you change CI behavior, check `.github/workflows/validate.yml`. - If you change publish behavior, check `.github/workflows/publish.yml`. @@ -49,6 +51,8 @@ These instructions apply to the whole repository. - Keep the example app buildable and analyzable. - Prefer minimal demo-oriented fixes in `example/`; avoid production-grade abstractions there unless requested. - If a dependency API deprecates, update the example usage when it is easy and low-risk. +- Android example smoke coverage exists in `example/integration_test/app_smoke_test.dart`; keep it stable when changing example navigation or consent flow. +- iOS runtime smoke coverage is not automated yet; do not claim parity unless it was explicitly tested. ## Analyzer Hygiene diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 81d1464..12c606d 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -36,34 +36,73 @@ cd example && flutter pub get && cd .. ## 2. Running Tests -**All unit tests:** +This repository currently has three test layers. + +### 2.1 Package unit test suite + +Run the full package-level Dart test suite: ```bash flutter test ``` -**Specific test file:** +Purpose: +- Validates the public Dart API of the plugin +- Verifies method-channel calls and serialized arguments +- Covers WebTrackingController behavior without requiring a real device +- Fastest regression signal; should be run after any Dart or API change + +Main files in this suite: + +| File | Description | Purpose | +|---|---|---| +| `test/plugin_mappintelligence_test.dart` | Method-channel focused unit tests for `PluginMappintelligence` public APIs | Ensures plugin methods call the correct native channel methods, pass the expected arguments, and keep version sync with `pubspec.yaml` | +| `test/web_tracking_controller_test.dart` | Unit tests for `WebTrackingController` using fake WebView platform classes | Protects the WebView/session-linking integration: callback forwarding, EverID injection, JavaScript message handling, and failure-path behavior | + +Run an individual package test file: ```bash -flutter test test/web_tracking_controller_test.dart flutter test test/plugin_mappintelligence_test.dart +flutter test test/web_tracking_controller_test.dart ``` -**With verbose output:** +Useful targeted runs: ```bash +flutter test --name "version sync" flutter test --reporter expanded ``` -**What the tests cover:** +`version sync` specifically verifies that `pubspec.yaml` matches the hardcoded plugin version used in `lib/plugin_mappintelligence.dart`. -| File | Coverage | -|---|---| -| `test/plugin_mappintelligence_test.dart` | All public API methods, channel argument verification, version sync between `pubspec.yaml` and source code | -| `test/web_tracking_controller_test.dart` | NavigationDelegate forwarding, EverID injection ordering, onLoad success/failure behavior, JavaScript channel dispatch | +### 2.2 Example app integration smoke test -**Version sync test** — verifies `pubspec.yaml` version matches the hardcoded string in `lib/plugin_mappintelligence.dart`: +Run the example app smoke test on a device or emulator: ```bash -flutter test --name "version sync" +cd example +flutter test integration_test/app_smoke_test.dart -d ``` +Main file in this suite: + +| File | Description | Purpose | +|---|---|---| +| `example/integration_test/app_smoke_test.dart` | Runtime smoke test for the real example application | Verifies the app launches, consent flow works, navigation renders correctly, and the plugin is registered in a real app environment | + +Purpose: +- Exercises the plugin from a real Flutter app instead of mocked unit-only code +- Catches runtime integration regressions that unit tests cannot see +- Intended as a smoke test, not full feature automation + +Current scope: +- Android smoke coverage is automated in CI +- iOS runtime smoke coverage is not automated yet + +### 2.3 Example widget test + +The example app also contains: + +| File | Description | Purpose | +|---|---|---| +| `example/test/widget_test.dart` | Legacy example widget test | Currently low-value and not the main regression signal; the integration smoke test is more important for runtime coverage | + --- ## 3. Running the Example App @@ -80,6 +119,16 @@ The example app demonstrates all tracking features: - Exception tracking - Form tracking +For runtime regression coverage, the example app also contains an Android smoke test: +```bash +cd example +flutter test integration_test/app_smoke_test.dart -d +``` + +Current scope: +- Android smoke coverage is automated in CI +- iOS runtime smoke coverage is not automated yet + --- ## 4. Making a Release @@ -114,19 +163,26 @@ flutter test --name "version sync" flutter test ``` -### Step 6 — Dry run +### Step 6 — Run example smoke test +```bash +cd example +flutter test integration_test/app_smoke_test.dart -d +cd .. +``` + +### Step 7 — Dry run ```bash dart pub publish --dry-run ``` -### Step 7 — Commit and push to `main` +### Step 8 — Commit and push to `main` ```bash git add -A git commit -m "chore: release " git push origin main ``` -The `validate.yml` GitHub Actions workflow runs automatically on every push to `main` — it verifies formatting, analysis, tests, and a dry run. Wait for it to pass before proceeding to publish. +The `validate.yml` GitHub Actions workflow runs on pull requests to `main`. It calls the shared release checks workflow, which verifies analysis, unit tests, Android integration smoke coverage, and a publish dry run. Wait for it to pass before proceeding to publish. --- @@ -166,8 +222,9 @@ git push origin v This automatically triggers the **Publish to pub.dev** workflow which: 1. Runs formatting check, analysis, and all tests -2. Publishes the package to pub.dev via OIDC -3. Creates a GitHub Release with the tag name and changelog notes extracted from `CHANGELOG.md` +2. Runs the Android example integration smoke test +3. Publishes the package to pub.dev via OIDC +4. Creates a GitHub Release with the tag name and changelog notes extracted from `CHANGELOG.md` Monitor the run at: `https://github.com/mapp-digital/Mapp-Intelligence-Flutter-Tracking/actions` @@ -188,6 +245,11 @@ flutter analyze --no-fatal-infos # Tests flutter test +# Android integration smoke +cd example +flutter test integration_test/app_smoke_test.dart -d +cd .. + # Dry run — validates package structure without publishing dart pub publish --dry-run ``` @@ -213,5 +275,11 @@ The version heading in `CHANGELOG.md` must match exactly `## ` with no ### OIDC authentication fails in CI Verify the pub.dev admin setup in Section 5 is complete and the repository name matches exactly: `mapp-digital/Mapp-Intelligence-Flutter-Tracking`. +### Android smoke test fails in CI +The shared release checks workflow runs `example/integration_test/app_smoke_test.dart` on an Android emulator. Check: +- emulator boot failures in the `integration-smoke` job +- example dependency resolution +- UI text or navigation changes in the example app that invalidate the smoke test assertions + ### Publish workflow triggers but GitHub Release is not created The `github-release` job requires the `publish` job to succeed first. Check the Actions run log for errors in the publish step. diff --git a/example/integration_test/app_smoke_test.dart b/example/integration_test/app_smoke_test.dart new file mode 100644 index 0000000..f3b9715 --- /dev/null +++ b/example/integration_test/app_smoke_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:plugin_mappintelligence_example/main.dart' as app; + +Future _pumpUntilVisible( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), + Duration step = const Duration(milliseconds: 250), +}) async { + final maxTicks = timeout.inMilliseconds ~/ step.inMilliseconds; + for (var i = 0; i < maxTicks; i++) { + await tester.pump(step); + if (finder.evaluate().isNotEmpty) { + return; + } + } + fail('Timed out waiting for expected widget to appear.'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('example app launches and basic navigation works', + (WidgetTester tester) async { + app.main(); + + final consentDialog = find.text('User Tracking'); + await _pumpUntilVisible(tester, consentDialog); + expect(consentDialog, findsOneWidget); + + await tester.tap(find.text('Ok')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text('Mapp Intelligence Demo'), findsOneWidget); + expect(find.text('Page Tracking'), findsOneWidget); + expect(find.text('Webview'), findsOneWidget); + + await tester.tap(find.text('Page Tracking')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text('Track Page'), findsOneWidget); + expect(find.text('Track Custom Page'), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text('Mapp Intelligence Demo'), findsOneWidget); + }); +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b909e19..ed65ce2 100755 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -32,6 +32,8 @@ dev_dependencies: video_player: ^2.1.1 flutter_test: sdk: flutter + integration_test: + sdk: flutter # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/plugin_mappintelligence_test.dart b/test/plugin_mappintelligence_test.dart index 2efd0af..8d25ff4 100755 --- a/test/plugin_mappintelligence_test.dart +++ b/test/plugin_mappintelligence_test.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_mappintelligence/object_tracking_classes.dart'; @@ -10,10 +12,20 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel('plugin_mappintelligence'); + final originalDebugPrint = debugPrint; // Captures every method call made through the channel. final List log = []; + Future runQuietly(FutureOr Function() body) { + return runZoned( + () async => await body(), + zoneSpecification: ZoneSpecification( + print: (_, __, ___, ____) {}, + ), + ); + } + // Default handler — returns sensible values so methods don't throw. Future defaultHandler(MethodCall call) async { log.add(call); @@ -23,7 +35,10 @@ void main() { case 'setEverId': return 'ok'; case 'getIdsAndDomain': - return {'trackIds': ['123'], 'trackDomain': 'example.com'}; + return { + 'trackIds': ['123'], + 'trackDomain': 'example.com' + }; case 'getCurrentConfig': return {'key': 'value'}; case 'resetConfig': @@ -49,11 +64,13 @@ void main() { setUp(() { log.clear(); + debugPrint = (String? message, {int? wrapWidth}) {}; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, defaultHandler); }); tearDown(() { + debugPrint = originalDebugPrint; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); }); @@ -90,7 +107,8 @@ void main() { group('initialize', () { test('invokes initialize with trackIds and trackDomain', () async { - await PluginMappintelligence.initialize(['123456789'], 'track.example.com'); + await PluginMappintelligence.initialize( + ['123456789'], 'track.example.com'); final call = lastCall(); expect(call.method, 'initialize'); expect(call.arguments['trackIds'], ['123456789']); @@ -230,10 +248,14 @@ void main() { expect(lastCall().arguments, ['Home']); }); - test('with params invokes trackCustomPage with name and params map', () async { + test('with params invokes trackCustomPage with name and params map', + () async { await PluginMappintelligence.trackPage('Home', {'key': 'value'}); expect(lastCall().method, 'trackCustomPage'); - expect(lastCall().arguments, ['Home', {'key': 'value'}]); + expect(lastCall().arguments, [ + 'Home', + {'key': 'value'} + ]); }); }); @@ -242,13 +264,15 @@ void main() { // --------------------------------------------------------------------------- group('trackPageWithCustomData', () { - test('with customName invokes trackPageWithCustomNameAndPageViewEvent', () async { + test('with customName invokes trackPageWithCustomNameAndPageViewEvent', + () async { await PluginMappintelligence.trackPageWithCustomData(null, 'MyPage'); expect(lastCall().method, 'trackPageWithCustomNameAndPageViewEvent'); expect(lastCall().arguments, ['MyPage']); }); - test('with pageViewEvent invokes trackPageWithCustomData with JSON', () async { + test('with pageViewEvent invokes trackPageWithCustomData with JSON', + () async { final event = PageViewEvent('ProductPage'); await PluginMappintelligence.trackPageWithCustomData(event); expect(lastCall().method, 'trackPageWithCustomData'); @@ -265,6 +289,11 @@ void main() { expect(decoded['pageParameters']['searchTerm'], 'shoes'); expect(decoded['ecommerceParameters']['currency'], 'EUR'); }); + + test('with null inputs does not dispatch a native call', () async { + await PluginMappintelligence.trackPageWithCustomData(null); + expect(log, isEmpty); + }); }); // --------------------------------------------------------------------------- @@ -296,6 +325,23 @@ void main() { expect(decoded['name'], 'ButtonClick'); expect(decoded['eventParameters']['parameters']['1'], 'param_value'); }); + + test('propagates native failure', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + if (call.method == 'trackAction') { + throw PlatformException(code: 'TRACK_FAILED'); + } + return defaultHandler(call); + }); + + final event = ActionEvent('ButtonClick'); + await expectLater( + runQuietly(() => PluginMappintelligence.trackAction(event)), + throwsA(isA()), + ); + }); }); // --------------------------------------------------------------------------- @@ -342,13 +388,16 @@ void main() { group('trackWebview', () { test('with all coordinates sends x, y, width, height, url', () async { - await PluginMappintelligence.trackWebview(0, 0, 320, 480, 'https://example.com'); + await PluginMappintelligence.trackWebview( + 0, 0, 320, 480, 'https://example.com'); expect(lastCall().method, 'trackWebview'); - expect(lastCall().arguments, [0.0, 0.0, 320.0, 480.0, 'https://example.com']); + expect(lastCall().arguments, + [0.0, 0.0, 320.0, 480.0, 'https://example.com']); }); test('with null coordinates sends only url', () async { - await PluginMappintelligence.trackWebview(null, null, null, null, 'https://example.com'); + await PluginMappintelligence.trackWebview( + null, null, null, null, 'https://example.com'); expect(lastCall().method, 'trackWebview'); expect(lastCall().arguments, ['https://example.com']); }); @@ -404,6 +453,20 @@ void main() { expect(data?['trackIds'], ['123']); expect(data?['trackDomain'], 'example.com'); }); + + test('returns null when native returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + if (call.method == 'getIdsAndDomain') { + return null; + } + return defaultHandler(call); + }); + + final data = await PluginMappintelligence.getTrackIdsAndDomain(); + expect(data, isNull); + }); }); // --------------------------------------------------------------------------- @@ -496,7 +559,8 @@ void main() { group('trackError', () { test('sends userInfo, domain and code', () async { - await PluginMappintelligence.trackError({'key': 'val'}, 'com.example', 42); + await PluginMappintelligence.trackError( + {'key': 'val'}, 'com.example', 42); expect(lastCall().method, 'trackError'); expect(lastCall().arguments['domain'], 'com.example'); expect(lastCall().arguments['code'], 42); @@ -509,7 +573,8 @@ void main() { // --------------------------------------------------------------------------- group('version sync', () { - test('flutterPluginVersion in _updateCustomParams matches pubspec.yaml', () async { + test('flutterPluginVersion in _updateCustomParams matches pubspec.yaml', + () async { // Read pubspec.yaml from the package root (two levels up from test/) final pubspecFile = File('pubspec.yaml'); final pubspecContent = await pubspecFile.readAsString(); @@ -517,7 +582,8 @@ void main() { // Extract version: value without adding a yaml parser dependency final match = RegExp(r'^version:\s*(\S+)', multiLine: true) .firstMatch(pubspecContent); - expect(match, isNotNull, reason: 'version field not found in pubspec.yaml'); + expect(match, isNotNull, + reason: 'version field not found in pubspec.yaml'); final pubspecVersion = match!.group(1)!; // Trigger _updateCustomParams via build() and capture the channel call @@ -527,15 +593,17 @@ void main() { return 'ok'; }); - await PluginMappintelligence.build(); + await runQuietly(() => PluginMappintelligence.build()); - final updateCall = log.firstWhere((c) => c.method == 'updateCustomParams'); + final updateCall = + log.firstWhere((c) => c.method == 'updateCustomParams'); final hardcodedVersion = (updateCall.arguments as List).first as String; expect( hardcodedVersion, pubspecVersion, - reason: 'flutterPluginVersion in plugin_mappintelligence.dart ($hardcodedVersion) ' + reason: + 'flutterPluginVersion in plugin_mappintelligence.dart ($hardcodedVersion) ' 'is out of sync with pubspec.yaml ($pubspecVersion). ' 'Update the version string in _updateCustomParams().', ); diff --git a/test/web_tracking_controller_test.dart b/test/web_tracking_controller_test.dart index 225bc27..aa865b8 100644 --- a/test/web_tracking_controller_test.dart +++ b/test/web_tracking_controller_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -46,15 +48,19 @@ class FakeNavigationDelegate extends PlatformNavigationDelegate { WebResourceErrorCallback? _onWebResourceError; NavigationRequestCallback? _onNavigationRequest; @override - Future setOnPageStarted(PageEventCallback cb) async => _onPageStarted = cb; + Future setOnPageStarted(PageEventCallback cb) async => + _onPageStarted = cb; @override - Future setOnPageFinished(PageEventCallback cb) async => _onPageFinished = cb; + Future setOnPageFinished(PageEventCallback cb) async => + _onPageFinished = cb; @override Future setOnProgress(ProgressCallback cb) async => _onProgress = cb; @override - Future setOnWebResourceError(WebResourceErrorCallback cb) async => _onWebResourceError = cb; + Future setOnWebResourceError(WebResourceErrorCallback cb) async => + _onWebResourceError = cb; @override - Future setOnNavigationRequest(NavigationRequestCallback cb) async => _onNavigationRequest = cb; + Future setOnNavigationRequest(NavigationRequestCallback cb) async => + _onNavigationRequest = cb; @override Future setOnUrlChange(UrlChangeCallback cb) async {} @override @@ -64,10 +70,13 @@ class FakeNavigationDelegate extends PlatformNavigationDelegate { // Simulation helpers void simulatePageStarted(String url) => _onPageStarted?.call(url); - Future simulatePageFinished(String url) async => _onPageFinished?.call(url); + Future simulatePageFinished(String url) async => + _onPageFinished?.call(url); void simulateProgress(int progress) => _onProgress?.call(progress); - void simulateWebResourceError(WebResourceError error) => _onWebResourceError?.call(error); - Future simulateNavigationRequest(NavigationRequest req) async => + void simulateWebResourceError(WebResourceError error) => + _onWebResourceError?.call(error); + Future simulateNavigationRequest( + NavigationRequest req) async => await _onNavigationRequest?.call(req) ?? NavigationDecision.navigate; } @@ -85,7 +94,8 @@ class FakeWebViewController extends PlatformWebViewController { Future setBackgroundColor(Color color) async {} @override - Future setPlatformNavigationDelegate(PlatformNavigationDelegate handler) async { + Future setPlatformNavigationDelegate( + PlatformNavigationDelegate handler) async { capturedDelegate = handler as FakeNavigationDelegate; } @@ -124,6 +134,15 @@ FakeWebViewController _fakePlatformController(WebViewController controller) { return controller.platform as FakeWebViewController; } +Future _runQuietly(FutureOr Function() body) { + return runZoned( + () async => await body(), + zoneSpecification: ZoneSpecification( + print: (_, __, ___, ____) {}, + ), + ); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -189,8 +208,10 @@ void main() { // (demonstrates what the issue was before the fix) // ------------------------------------------------------------------------- - test('REGRESSION: without fix, a second setNavigationDelegate call would ' - 'override the first — verified by confirming only one delegate is active', () { + test( + 'REGRESSION: without fix, a second setNavigationDelegate call would ' + 'override the first — verified by confirming only one delegate is active', + () { // Before the fix, clients had to call setNavigationDelegate themselves, // which WebTrackingController then replaced. Now clients pass their // callbacks via navigationDelegate parameter — only one delegate is set. @@ -274,11 +295,28 @@ void main() { expect(clientCalled, isTrue); }); + test('onNavigationRequest preserves prevent decision from client callback', + () async { + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onNavigationRequest: (_) => NavigationDecision.prevent, + ), + ); + + final decision = + await fakeController.capturedDelegate!.simulateNavigationRequest( + NavigationRequest(url: 'https://example.com', isMainFrame: true), + ); + expect(decision, NavigationDecision.prevent); + }); + // ------------------------------------------------------------------------- // 4. onPageFinished ordering — client fires AFTER EverID injection // ------------------------------------------------------------------------- - test('onPageFinished: plugin injects EverID before client callback fires', () async { + test('onPageFinished: plugin injects EverID before client callback fires', + () async { final log = []; WebTrackingController( @@ -288,20 +326,26 @@ void main() { ), ); - await fakeController.capturedDelegate!.simulatePageFinished('https://example.com'); + await fakeController.capturedDelegate! + .simulatePageFinished('https://example.com'); // Flush the full async chain: handleLoad (method channel) → runJavaScript → .then(client cb) await Future.delayed(const Duration(milliseconds: 50)); - expect(fakeController.jsLog.any((js) => js.contains('webtrekkApplicationEverId')), - isTrue, reason: 'EverID script must have been injected'); + expect( + fakeController.jsLog + .any((js) => js.contains('webtrekkApplicationEverId')), + isTrue, + reason: 'EverID script must have been injected'); expect(log, contains('client'), reason: 'Client onPageFinished must fire after injection'); }); - test('onPageFinished: EverID value from native is injected into the page', () async { + test('onPageFinished: EverID value from native is injected into the page', + () async { WebTrackingController(controller: controller); - await fakeController.capturedDelegate!.simulatePageFinished('https://example.com'); + await fakeController.capturedDelegate! + .simulatePageFinished('https://example.com'); await Future.delayed(const Duration(milliseconds: 50)); expect( @@ -338,10 +382,35 @@ void main() { onLoad: () => onLoadCalled = true, ); - await wt.handleLoad(); + await _runQuietly(() => wt.handleLoad()); expect(onLoadCalled, isFalse); }); + test('client onPageFinished still fires when EverID injection fails', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getEverId') throw PlatformException(code: 'ERROR'); + return null; + }); + + bool clientCalled = false; + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onPageFinished: (_) => clientCalled = true, + ), + ); + + await _runQuietly(() async { + await fakeController.capturedDelegate! + .simulatePageFinished('https://example.com'); + await Future.delayed(const Duration(milliseconds: 50)); + }); + + expect(clientCalled, isTrue); + }); + // ------------------------------------------------------------------------- // 6. JavaScript channel message dispatch // ------------------------------------------------------------------------- @@ -352,12 +421,15 @@ void main() { .firstWhere((c) => c.name == 'ReactNativeWebView'); expect( - () => channel.onMessageReceived(JavaScriptMessage(message: 'not-json')), + () => _runQuietly( + () => channel.onMessageReceived(JavaScriptMessage(message: 'not-json')), + ), returnsNormally, ); }); - test('message missing method/name fields does not dispatch tracking call', () { + test('message missing method/name fields does not dispatch tracking call', + () { final methodCalls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { @@ -369,7 +441,10 @@ void main() { final jsChannel = fakeController.channels .firstWhere((c) => c.name == 'ReactNativeWebView'); - jsChannel.onMessageReceived(JavaScriptMessage(message: '{"foo":"bar"}')); + _runQuietly( + () => jsChannel + .onMessageReceived(JavaScriptMessage(message: '{"foo":"bar"}')), + ); expect(methodCalls, isNot(contains('trackWebPage'))); expect(methodCalls, isNot(contains('trackWebEvent'))); }); @@ -384,7 +459,63 @@ void main() { final jsChannel = fakeController.channels .firstWhere((c) => c.name == 'ReactNativeWebView'); - jsChannel.onMessageReceived(JavaScriptMessage(message: 'hello')); + _runQuietly( + () => jsChannel.onMessageReceived(JavaScriptMessage(message: 'hello')), + ); expect(received, contains('hello')); }); + + test('trackCustomPage message dispatches trackWebPage', () async { + final methodCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + methodCalls.add(call); + return null; + }); + + WebTrackingController(controller: controller); + final jsChannel = fakeController.channels + .firstWhere((c) => c.name == 'ReactNativeWebView'); + + await _runQuietly( + () => jsChannel.onMessageReceived( + JavaScriptMessage( + message: + '{"method":"trackCustomPage","name":"Home","params":"{\\"foo\\":\\"bar\\"}"}', + ), + ), + ); + + expect( + methodCalls.any((call) => call.method == 'trackWebPage'), + Platform.isAndroid, + ); + }); + + test('trackCustomEvent message dispatches trackWebEvent', () async { + final methodCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + methodCalls.add(call); + return null; + }); + + WebTrackingController(controller: controller); + final jsChannel = fakeController.channels + .firstWhere((c) => c.name == 'ReactNativeWebView'); + + await _runQuietly( + () => jsChannel.onMessageReceived( + JavaScriptMessage( + message: + '{"method":"trackCustomEvent","name":"CTA","params":"{\\"foo\\":\\"bar\\"}"}', + ), + ), + ); + + expect( + methodCalls.any((call) => call.method == 'trackWebEvent'), + Platform.isAndroid, + ); + }); }