diff --git a/.github/scripts/macos_payment_checkout_smoke.sh b/.github/scripts/macos_payment_smoke.sh similarity index 59% rename from .github/scripts/macos_payment_checkout_smoke.sh rename to .github/scripts/macos_payment_smoke.sh index 2d78afff21..0c22a63e0e 100644 --- a/.github/scripts/macos_payment_checkout_smoke.sh +++ b/.github/scripts/macos_payment_smoke.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -TEST_PATH="${TEST_PATH:-integration_test/payment/desktop_stripe_checkout_smoke_test.dart}" -ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos-payment-checkout}" +TEST_PATH="${TEST_PATH:-integration_test/payment/desktop_payment_smoke_test.dart}" +ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos-payment-smoke}" LANTERN_DATA_DIR="/Users/Shared/Lantern" LANTERN_LOG_DIR="$LANTERN_DATA_DIR/Logs" SCREENSHOT_PATH="$ARTIFACT_DIR/stripe-checkout.png" @@ -17,20 +17,29 @@ cleanup() { exit "$status" } +# This script deletes the shared Lantern data directory. +if [[ -z "${CI:-}" ]]; then + echo "Refusing to run outside CI: this wipes $LANTERN_DATA_DIR" >&2 + exit 1 +fi + trap cleanup EXIT pkill -x Lantern 2>/dev/null || true rm -rf "$LANTERN_DATA_DIR" +mkdir -p "$LANTERN_DATA_DIR" mkdir -p "$LANTERN_LOG_DIR" mkdir -p "$ARTIFACT_DIR" +# The debug test build reads this marker before initializing Radiance. +touch "$LANTERN_DATA_DIR/.radiance_env" + flutter test \ "$TEST_PATH" \ -d macos \ --reporter=expanded \ --dart-define=DISABLE_SYSTEM_TRAY=true \ - --dart-define=PAYMENT_SMOKE_SCREENSHOT_PATH="$SCREENSHOT_PATH" \ - --dart-define=RADIANCE_ENV=staging + --dart-define=PAYMENT_SMOKE_ARTIFACT_DIR="$ARTIFACT_DIR" if [[ ! -s "$SCREENSHOT_PATH" ]]; then echo "Stripe Checkout screenshot was not created" >&2 diff --git a/.github/scripts/windows_smoke_suite.ps1 b/.github/scripts/windows_smoke_suite.ps1 index dde63c9bca..6729612092 100644 --- a/.github/scripts/windows_smoke_suite.ps1 +++ b/.github/scripts/windows_smoke_suite.ps1 @@ -3,7 +3,7 @@ param( [string]$ServiceExe = "build/windows/x64/runner/Release/lanternd.exe", [string]$InstallerPath = "", [string]$TestPath = "integration_test/vpn/windows_connect_smoke_test.dart", - [string]$PaymentCheckoutTestPath = "integration_test/payment/desktop_stripe_checkout_smoke_test.dart", + [string]$PaymentSmokeTestPath = "integration_test/payment/desktop_payment_smoke_test.dart", [string]$SplitTunnelWebsiteTestPath = "integration_test/vpn/split_tunneling_website_smoke_test.dart", [string]$ConfigUrlApiTestPath = "integration_test/vpn/windows_config_url_api_smoke_test.dart", [string]$ConfigUrlUiTestPath = "integration_test/vpn/windows_config_url_smoke_test.dart", @@ -19,7 +19,8 @@ param( [switch]$ForceFullTunnel, [switch]$RunSplitTunnelWebsiteSmoke, [switch]$RunConfigUrlSmoke, - [switch]$RunPaymentCheckoutSmoke, + [switch]$RunPaymentSmoke, + [string]$PaymentSmokeArtifactDirectory = "build/windows-payment-smoke", [switch]$UseInstaller ) @@ -136,11 +137,12 @@ function Invoke-FlutterSmokeTest { [string]$Description, [ValidateSet("prod", "staging")] [string]$RadianceEnvironment = "prod", + [string[]]$DartDefines = @(), [switch]$EnableIpCheck, [switch]$ForceFullTunnel ) - $args = @( + $flutterArgs = @( "test", $Path, "-d", @@ -149,18 +151,22 @@ function Invoke-FlutterSmokeTest { "--dart-define=DISABLE_SYSTEM_TRAY=true" ) - $args += "--dart-define=RADIANCE_ENV=$RadianceEnvironment" + $flutterArgs += "--dart-define=RADIANCE_ENV=$RadianceEnvironment" + + foreach ($define in $DartDefines) { + $flutterArgs += "--dart-define=$define" + } if ($EnableIpCheck) { - $args += "--dart-define=ENABLE_IP_CHECK=true" + $flutterArgs += "--dart-define=ENABLE_IP_CHECK=true" } if ($ForceFullTunnel) { - $args += "--dart-define=SMOKE_FORCE_FULL_TUNNEL=true" + $flutterArgs += "--dart-define=SMOKE_FORCE_FULL_TUNNEL=true" } - Write-Step ("Running {0}: flutter {1}" -f $Description, ($args -join " ")) - & flutter @args + Write-Step ("Running {0}: flutter {1}" -f $Description, ($flutterArgs -join " ")) + & flutter @flutterArgs if ($LASTEXITCODE -ne 0) { throw "$Description failed with exit code $LASTEXITCODE" } @@ -330,13 +336,21 @@ try { Write-Step "Skipping Windows connect smoke test." } - if ($RunPaymentCheckoutSmoke) { + if ($RunPaymentSmoke) { + if ($ServiceEnvironment -ne "staging") { + throw "Payment smoke requires -ServiceEnvironment staging (got '$ServiceEnvironment')" + } + $paymentDefines = @( + "PAYMENT_E2E_ENABLED=true", + "PAYMENT_SMOKE_ARTIFACT_DIR=$PaymentSmokeArtifactDirectory" + ) Invoke-FlutterSmokeTest ` - -Path $PaymentCheckoutTestPath ` - -Description "Windows Stripe checkout smoke test" ` - -RadianceEnvironment $ServiceEnvironment + -Path $PaymentSmokeTestPath ` + -Description "Windows payment smoke test" ` + -RadianceEnvironment $ServiceEnvironment ` + -DartDefines $paymentDefines } else { - Write-Step "Skipping Windows Stripe checkout smoke test." + Write-Step "Skipping Windows payment smoke test." } if ($RunSplitTunnelWebsiteSmoke) { @@ -384,6 +398,18 @@ try { } } finally { + if ($RunPaymentSmoke) { + try { + $paymentLogDirectory = Join-Path $PaymentSmokeArtifactDirectory "logs" + New-Item -ItemType Directory -Force -Path $paymentLogDirectory | Out-Null + $lanternLogDirectory = "C:\Users\Public\Lantern\logs" + if (Test-Path $lanternLogDirectory) { + Copy-Item "$lanternLogDirectory\*" $paymentLogDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } catch { + Write-Warning ("Failed to collect payment smoke logs: {0}" -f $_) + } + } try { Write-Step "Starting cleanup" if ($UseInstaller) { diff --git a/.github/workflows/app-smoke-tests.yml b/.github/workflows/app-smoke-tests.yml index d973e9b802..c2dd4f1ae2 100644 --- a/.github/workflows/app-smoke-tests.yml +++ b/.github/workflows/app-smoke-tests.yml @@ -22,6 +22,7 @@ on: type: choice options: - vpn-smoke # connect/disconnect smoke only (validates the public IP changes) — the fast confidence check + - payment-smoke # Stripe Checkout rendering and payment-to-Pro conversion - all # every suite the platform supports default: vpn-smoke linux_arch: @@ -53,7 +54,7 @@ jobs: # retries; `all` builds the full aggregator on the script's two-device # matrix (physical Pixel 8 + Arm virtual) with retries for flaky tests. android: - if: ${{ contains(fromJSON('["all", "android", ""]'), inputs.platforms) }} + if: ${{ contains(fromJSON('["all", "android", ""]'), inputs.platforms) && (inputs.tests || 'all') != 'payment-smoke' }} uses: ./.github/workflows/firebase-test-lab.yml secrets: inherit with: @@ -107,7 +108,7 @@ jobs: # to the optional suites: vpn-smoke leaves them off, `all` turns them on. linux: needs: prepare - if: ${{ contains(fromJSON('["all", "linux", ""]'), inputs.platforms) }} + if: ${{ contains(fromJSON('["all", "linux", ""]'), inputs.platforms) && (inputs.tests || 'all') != 'payment-smoke' }} uses: ./.github/workflows/build-linux.yml secrets: inherit with: @@ -131,8 +132,8 @@ jobs: build_type: nightly installer_base_name: ${{ needs.prepare.outputs.installer_base_name }} runner_label: lantern-macos-smoke - run_connect_smoke: true - run_payment_checkout_smoke: ${{ (inputs.tests || 'all') == 'all' }} + run_connect_smoke: ${{ (inputs.tests || 'all') != 'payment-smoke' }} + run_payment_smoke: ${{ contains(fromJSON('["all", "payment-smoke"]'), inputs.tests || 'all') }} windows: needs: prepare @@ -145,6 +146,7 @@ jobs: installer_base_name: ${{ needs.prepare.outputs.installer_base_name }} skip_signing: true force_full_tunnel_smoke: true + run_connect_smoke: ${{ (inputs.tests || 'all') != 'payment-smoke' }} run_auth_smoke: ${{ (inputs.tests || 'all') == 'all' }} run_split_tunnel_website_smoke: ${{ (inputs.tests || 'all') == 'all' }} - run_payment_checkout_smoke: ${{ (inputs.tests || 'all') == 'all' }} + run_payment_smoke: ${{ contains(fromJSON('["all", "payment-smoke"]'), inputs.tests || 'all') }} diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 730cb290ba..387d82487e 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -37,7 +37,7 @@ on: required: false type: boolean default: false - run_payment_checkout_smoke: + run_payment_smoke: description: "Verify staging Stripe Checkout in a Flutter integration test" required: false type: boolean @@ -201,21 +201,21 @@ jobs: - name: Run macOS unit tests run: make macos-unit-tests - - name: macOS Stripe checkout smoke - if: ${{ inputs.run_payment_checkout_smoke }} + - name: macOS payment smoke + if: ${{ inputs.run_payment_smoke }} timeout-minutes: 15 shell: bash env: - ARTIFACT_DIR: ${{ runner.temp }}/macos-payment-checkout-smoke - run: bash ./.github/scripts/macos_payment_checkout_smoke.sh + ARTIFACT_DIR: ${{ runner.temp }}/macos-payment-smoke + run: bash ./.github/scripts/macos_payment_smoke.sh - name: Upload macOS payment smoke diagnostics - if: ${{ always() && inputs.run_payment_checkout_smoke }} + if: ${{ always() && inputs.run_payment_smoke }} id: payment_smoke_diagnostics uses: actions/upload-artifact@v4 with: - name: macos-payment-checkout-smoke - path: ${{ runner.temp }}/macos-payment-checkout-smoke + name: macos-payment-smoke + path: ${{ runner.temp }}/macos-payment-smoke if-no-files-found: ignore retention-days: 2 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2d0c1a01ba..23f386a78a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -34,6 +34,11 @@ on: required: false type: boolean default: true + run_payment_smoke: + description: "Verify staging checkout rendering and payment-to-Pro conversion" + required: false + type: boolean + default: false run_split_tunnel_website_smoke: description: "Run website split tunneling smoke test" required: false @@ -54,11 +59,6 @@ on: required: false type: boolean default: false - run_payment_checkout_smoke: - description: "Verify staging Stripe Checkout in a Flutter integration test" - required: false - type: boolean - default: false jobs: build-windows: @@ -206,8 +206,8 @@ jobs: Write-Warning "Release directory does not exist" } - - name: Windows Stripe checkout smoke - if: ${{ inputs.run_payment_checkout_smoke }} + - name: Windows payment smoke + if: ${{ inputs.run_payment_smoke }} shell: pwsh timeout-minutes: 15 run: | @@ -215,7 +215,16 @@ jobs: -ServiceExe "build/windows/x64/runner/Release/lanternd.exe" ` -ServiceEnvironment staging ` -RunConnectSmoke:$false ` - -RunPaymentCheckoutSmoke + -RunPaymentSmoke + + - name: Upload Windows payment smoke diagnostics + if: ${{ always() && inputs.run_payment_smoke }} + uses: actions/upload-artifact@v4 + with: + name: windows-payment-smoke + path: build/windows-payment-smoke + if-no-files-found: ignore + retention-days: 7 - name: Sign embedded binaries if: ${{ !inputs.skip_signing }} diff --git a/integration_test/payment/desktop_payment_smoke_test.dart b/integration_test/payment/desktop_payment_smoke_test.dart new file mode 100644 index 0000000000..625b3d08b8 --- /dev/null +++ b/integration_test/payment/desktop_payment_smoke_test.dart @@ -0,0 +1,413 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' show max; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/plan_data.dart' as plan_models; +import 'package:lantern/core/widgets/app_webview.dart'; +import 'package:lantern/features/home/provider/home_notifier.dart'; +import 'package:lantern/features/plans/provider/plans_notifier.dart'; +import 'package:lantern/main.dart' as app; + +import '../utils/app_robot.dart'; +import '../utils/payment_robot.dart'; + +const _stripeHost = 'checkout.stripe.com'; +const _e2eHost = 'api.staging.iantem.io'; +const _artifactDirectory = String.fromEnvironment('PAYMENT_SMOKE_ARTIFACT_DIR'); +const _screenshotRenderTimeout = Duration(seconds: 30); +const _screenshotPollInterval = Duration(milliseconds: 250); +const _minimumScreenshotContrast = 64; +const _darkPixelLuminance = 192; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'staging checkout renders and converts to Pro', + (tester) async { + expect( + Platform.isWindows || Platform.isMacOS, + isTrue, + reason: 'Payment smoke tests run only on desktop', + ); + + final runningApp = await _launchApp(tester); + await _runStripeRenderSmoke(tester, runningApp); + if (Platform.isWindows) { + await _runPaymentConversionSmoke(tester, runningApp); + } + }, + timeout: const Timeout(Duration(minutes: 8)), + ); +} + +Future<_RunningApp> _launchApp(WidgetTester tester) async { + await app.main(); + final robot = AppRobot(tester); + await robot.waitForHomeReady(); + final payment = PaymentRobot(tester, robot); + final plans = await payment.loadPlans(); + expect(plans.plans, isNotEmpty, reason: 'Staging returned no plans'); + return _RunningApp(payment: payment, plans: plans); +} + +Future _runStripeRenderSmoke( + WidgetTester tester, + _RunningApp runningApp, +) async { + final stripe = runningApp.plans.providers.desktop.where( + (provider) => provider.providers.name == 'stripe', + ); + expect(stripe, isNotEmpty, reason: 'Staging did not return Stripe'); + expect( + stripe.first.providers.supportSubscription, + isTrue, + reason: 'Staging Stripe must support subscriptions', + ); + + runningApp.payment.selectBestValuePlan(runningApp.plans); + final checkout = _StripeCheckoutTracker(); + await runningApp.payment.openPaymentMethods( + email: e2eEmail(), + authFlow: AuthFlow.renewSubscription, + checkoutObserver: checkout, + ); + await runningApp.payment.startCheckout(provider: 'stripe'); + + await _waitFor( + tester, + () => checkout.finished, + timeout: const Duration(minutes: 3), + failure: () => checkout.failureMessage, + ); + + if (checkout.failure != null) { + fail('Stripe Checkout failed to load: ${checkout.failure}'); + } + expect(find.byKey(const ValueKey('app-webview')), findsOneWidget); + expect(checkout.uri?.host, _stripeHost); + expect(checkout.documentLength, greaterThan(0)); + expect(checkout.screenshot, isNotNull); + e2eLog( + 'Stripe Checkout rendered from $_stripeHost ' + '(${checkout.documentLength} document characters)', + ); +} + +Future _runPaymentConversionSmoke( + WidgetTester tester, + _RunningApp runningApp, +) async { + final runID = newE2ERunID(); + final e2eProvider = plan_models.Android( + method: 'E2E Checkout', + providers: plan_models.Provider( + name: 'e2e', + icons: const [], + supportSubscription: false, + ), + ); + plan_models.PlansData withE2EProvider(plan_models.PlansData plans) { + if (plans.providers.desktop.any( + (provider) => provider.providers.name == 'e2e', + )) { + return plans; + } + return plans.copyWith( + providers: plan_models.Providers( + android: plans.providers.android, + desktop: [e2eProvider, ...plans.providers.desktop], + ), + ); + } + + void ensureE2EProvider() { + final currentPlans = runningApp.container.read(plansProvider).value; + if (currentPlans == null) return; + final plansWithE2E = withE2EProvider(currentPlans); + if (identical(plansWithE2E, currentPlans)) return; + runningApp.container.read(plansProvider.notifier).updatePlans(plansWithE2E); + } + + final e2eProviderSubscription = runningApp.container.listen(plansProvider, ( + _, + next, + ) { + final plans = next.value; + if (plans == null || + plans.providers.desktop.any( + (provider) => provider.providers.name == 'e2e', + )) { + return; + } + scheduleMicrotask(ensureE2EProvider); + }); + addTearDown(e2eProviderSubscription.close); + + final currentPlans = runningApp.container.read(plansProvider).value; + final plansWithE2E = withE2EProvider(currentPlans ?? runningApp.plans); + runningApp.container.read(plansProvider.notifier).updatePlans(plansWithE2E); + runningApp.payment.selectBestValuePlan(plansWithE2E); + + final checkout = _PaymentConversionTracker(); + await runningApp.payment.openPaymentMethods( + email: e2eEmail(runID), + authFlow: AuthFlow.renewSubscription, + checkoutObserver: checkout, + ); + await runningApp.payment.startCheckout(provider: 'e2e'); + + await _waitFor( + tester, + () => checkout.completeClicked || checkout.failure != null, + timeout: const Duration(minutes: 2), + failure: () => checkout.failureMessage, + ); + if (checkout.failure != null) { + fail('E2E checkout failed: ${checkout.failure}'); + } + + await _waitFor( + tester, + () => + runningApp.container.read(homeProvider).value?.legacyUserData.isPro == + true, + timeout: const Duration(minutes: 2), + failure: () => 'The staging account did not become Pro', + ); + await _waitFor( + tester, + () => find.text('welcome_to_lantern_pro'.i18n).evaluate().isNotEmpty, + timeout: const Duration(seconds: 30), + failure: () => 'The Lantern Pro success dialog was not shown', + ); + + expect(checkout.uri?.host, _e2eHost); + expect(checkout.documentLength, greaterThan(0)); + expect(checkout.screenshot, isNotNull); + expect( + runningApp.container.read(homeProvider).value?.legacyUserData.isPro, + isTrue, + ); + e2eLog('Staging E2E checkout converted run $runID to Pro'); +} + +Future _waitForRenderedScreenshot( + Future Function() captureScreenshot, +) async { + final deadline = DateTime.now().add(_screenshotRenderTimeout); + Object? lastError; + while (DateTime.now().isBefore(deadline)) { + try { + final screenshot = await captureScreenshot().timeout( + const Duration(seconds: 5), + ); + if (screenshot != null && await _hasVisibleContent(screenshot)) { + return screenshot; + } + } catch (error) { + lastError = error; + } + await Future.delayed(_screenshotPollInterval); + } + final detail = lastError == null ? '' : ': $lastError'; + throw TimeoutException( + 'Checkout did not become visually ready$detail', + _screenshotRenderTimeout, + ); +} + +Future _saveScreenshot(Uint8List screenshot, String name) async { + if (_artifactDirectory.isEmpty) return; + final file = File('$_artifactDirectory${Platform.pathSeparator}$name'); + await file.parent.create(recursive: true); + await file.writeAsBytes(screenshot, flush: true); + e2eLog('Checkout screenshot saved to ${file.path}'); +} + +Future _hasVisibleContent(Uint8List screenshot) async { + if (screenshot.lengthInBytes <= 1024) return false; + + final codec = await ui.instantiateImageCodec(screenshot); + try { + final frame = await codec.getNextFrame(); + try { + if (frame.image.width <= 100 || frame.image.height <= 100) return false; + final data = await frame.image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + if (data == null) return false; + + var darkest = 255; + var lightest = 0; + var darkPixels = 0; + final pixelCount = data.lengthInBytes ~/ 4; + final minimumDarkPixels = max(64, pixelCount ~/ 1000); + for (var offset = 0; offset < data.lengthInBytes; offset += 4) { + final red = data.getUint8(offset); + final green = data.getUint8(offset + 1); + final blue = data.getUint8(offset + 2); + final luminance = (299 * red + 587 * green + 114 * blue) ~/ 1000; + if (luminance < darkest) darkest = luminance; + if (luminance > lightest) lightest = luminance; + if (luminance <= _darkPixelLuminance) darkPixels++; + if (lightest - darkest >= _minimumScreenshotContrast && + darkPixels >= minimumDarkPixels) { + return true; + } + } + return false; + } finally { + frame.image.dispose(); + } + } finally { + codec.dispose(); + } +} + +Future _waitFor( + WidgetTester tester, + bool Function() condition, { + required Duration timeout, + required String Function() failure, +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 200)); + if (condition()) return; + } + fail(failure()); +} + +bool _isJavaScriptTrue(Object? value) => + value == true || value == 1 || value?.toString().toLowerCase() == 'true'; + +class _RunningApp { + final PaymentRobot payment; + final plan_models.PlansData plans; + + const _RunningApp({required this.payment, required this.plans}); + + ProviderContainer get container => payment.container; +} + +/// Folds scoped WebView callbacks into a stateful Stripe checkout verdict. +class _StripeCheckoutTracker implements AppWebViewObserver { + Uri? uri; + int documentLength = 0; + Uint8List? screenshot; + String? lastFailure; + String? failure; + + bool get finished => uri?.host == _stripeHost || failure != null; + + String get failureMessage => lastFailure == null + ? 'Stripe Checkout did not load a non-empty document' + : 'Stripe Checkout did not load: $lastFailure'; + + @override + Future onPageLoaded( + Uri uri, { + required int documentLength, + required Future Function() captureScreenshot, + required Future Function(String source) evaluateJavaScript, + }) async { + if (uri.host != _stripeHost) return; + try { + screenshot = await _waitForRenderedScreenshot(captureScreenshot); + await _saveScreenshot(screenshot!, 'stripe-checkout.png'); + } catch (error) { + failure = 'Unable to capture WebView screenshot: $error'; + } + this.uri = uri; + this.documentLength = documentLength; + } + + @override + void onPageLoadFailed(Uri? uri, String reason) { + lastFailure = '${uri?.host ?? 'unknown host'}: $reason'; + if (uri?.host == _stripeHost) { + failure = reason; + } + } +} + +/// Tracks the staging checkout state and completes it through the WebView. +class _PaymentConversionTracker implements AppWebViewObserver { + static const _clickCompleteScript = r''' + (() => { + const candidates = Array.from( + document.querySelectorAll('button, input[type="button"], input[type="submit"], a') + ); + const complete = candidates.find((element) => { + const label = ( + element.innerText || element.value || element.textContent || '' + ).trim().toLowerCase(); + return label === 'complete'; + }); + if (!complete) return false; + complete.click(); + return true; + })() + '''; + + Uri? uri; + int documentLength = 0; + Uint8List? screenshot; + bool completeClicked = false; + bool _completionInProgress = false; + String? failure; + + String get failureMessage => + failure ?? 'The staging E2E completion control was not available'; + + @override + Future onPageLoaded( + Uri uri, { + required int documentLength, + required Future Function() captureScreenshot, + required Future Function(String source) evaluateJavaScript, + }) async { + if (uri.host != _e2eHost || completeClicked || _completionInProgress) { + return; + } + _completionInProgress = true; + this.uri = uri; + this.documentLength = documentLength; + + try { + screenshot = await _waitForRenderedScreenshot(captureScreenshot); + await _saveScreenshot(screenshot!, 'payment-conversion.png'); + + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (DateTime.now().isBefore(deadline)) { + final result = await evaluateJavaScript( + _clickCompleteScript, + ).timeout(const Duration(seconds: 5)); + if (_isJavaScriptTrue(result)) { + completeClicked = true; + return; + } + await Future.delayed(_screenshotPollInterval); + } + } catch (error) { + failure = 'Unable to complete the staging checkout: $error'; + } finally { + _completionInProgress = false; + } + } + + @override + void onPageLoadFailed(Uri? uri, String reason) { + if (uri == null || uri.host == _e2eHost) { + failure = '${uri?.host ?? 'unknown host'}: $reason'; + } + } +} diff --git a/integration_test/payment/desktop_stripe_checkout_smoke_test.dart b/integration_test/payment/desktop_stripe_checkout_smoke_test.dart deleted file mode 100644 index d8afaeaff3..0000000000 --- a/integration_test/payment/desktop_stripe_checkout_smoke_test.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:lantern/core/common/common.dart'; -import 'package:lantern/core/widgets/app_webview.dart'; -import 'package:lantern/features/plans/provider/plans_notifier.dart'; -import 'package:lantern/lantern_app.dart'; -import 'package:lantern/main.dart' as app; - -const _stripeHost = 'checkout.stripe.com'; -const _screenshotPath = String.fromEnvironment('PAYMENT_SMOKE_SCREENSHOT_PATH'); -const _screenshotRenderTimeout = Duration(seconds: 30); -const _screenshotPollInterval = Duration(milliseconds: 250); -const _minimumScreenshotContrast = 64; -const _darkPixelLuminance = 192; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets( - 'staging Stripe Checkout renders in the desktop WebView', - (tester) async { - expect( - Platform.isWindows || Platform.isMacOS, - isTrue, - reason: 'This smoke runs on Windows and macOS', - ); - - await app.main(); - await _waitFor( - tester, - () => find.byType(LanternApp).evaluate().isNotEmpty, - timeout: const Duration(seconds: 30), - failure: () => 'Lantern did not render its Flutter UI', - ); - - final appElement = find.byType(LanternApp).evaluate().first; - final container = ProviderScope.containerOf(appElement, listen: false); - final plansSubscription = container.listen(plansProvider, (_, _) {}); - addTearDown(plansSubscription.close); - - final plans = await container - .read(plansProvider.future) - .timeout(const Duration(seconds: 60)); - final stripe = plans.providers.desktop.where( - (provider) => provider.providers.name == 'stripe', - ); - expect(stripe, isNotEmpty, reason: 'Staging did not return Stripe'); - expect( - stripe.first.providers.supportSubscription, - isTrue, - reason: 'Staging Stripe must support subscriptions', - ); - expect(plans.plans, isNotEmpty, reason: 'Staging returned no plans'); - - final selectedPlan = plans.plans.firstWhere( - (plan) => plan.bestValue, - orElse: () => plans.plans.first, - ); - container.read(plansProvider.notifier).setSelectedPlan(selectedPlan); - - final observer = _StripeCheckoutObserver(); - await appRouter.replaceAll([ - ChoosePaymentMethod( - email: 'e2e+${_newUuid()}@getlantern.org', - authFlow: AuthFlow.renewSubscription, - checkoutObserver: observer, - ), - ]); - - final stripeProvider = find.byKey(const Key('payment.provider.stripe')); - await _waitFor( - tester, - () => stripeProvider.evaluate().isNotEmpty, - timeout: const Duration(seconds: 30), - failure: () => 'Stripe was not shown on the payment-method screen', - ); - - final checkoutButton = find.byKey(const Key('payment.checkout.stripe')); - if (checkoutButton.evaluate().isEmpty) { - await tester.tap(stripeProvider); - await tester.pump(const Duration(milliseconds: 300)); - } - await _waitFor( - tester, - () => checkoutButton.evaluate().isNotEmpty, - timeout: const Duration(seconds: 10), - failure: () => 'Stripe checkout button was not available', - ); - - await tester.ensureVisible(checkoutButton); - await tester.tap(checkoutButton); - - await _waitFor( - tester, - () => observer.finished, - timeout: const Duration(minutes: 3), - failure: () => observer.failureMessage, - ); - - if (observer.checkoutFailure != null) { - fail('Stripe Checkout failed to load: ${observer.checkoutFailure}'); - } - expect(find.byKey(const ValueKey('app-webview')), findsOneWidget); - expect(observer.uri?.host, _stripeHost); - expect(observer.documentLength, greaterThan(0)); - if (Platform.isMacOS) { - final screenshot = observer.screenshot; - if (screenshot == null) { - fail('The Stripe WebView did not return a screenshot'); - } - if (_screenshotPath.isNotEmpty) { - final file = File(_screenshotPath); - await file.parent.create(recursive: true); - await file.writeAsBytes(screenshot, flush: true); - debugPrint('Stripe Checkout screenshot saved to ${file.path}'); - } - } - debugPrint( - 'Stripe Checkout rendered from $_stripeHost ' - '(${observer.documentLength} document characters)', - ); - }, - timeout: const Timeout(Duration(minutes: 5)), - ); -} - -Future _waitForRenderedScreenshot( - Future Function() captureScreenshot, -) async { - final deadline = DateTime.now().add(_screenshotRenderTimeout); - Object? lastError; - while (DateTime.now().isBefore(deadline)) { - try { - final screenshot = await captureScreenshot().timeout( - const Duration(seconds: 5), - ); - if (screenshot != null && await _hasVisibleContent(screenshot)) { - return screenshot; - } - } catch (error) { - lastError = error; - } - await Future.delayed(_screenshotPollInterval); - } - final detail = lastError == null ? '' : ': $lastError'; - throw TimeoutException( - 'Stripe Checkout did not become visually ready$detail', - _screenshotRenderTimeout, - ); -} - -Future _hasVisibleContent(Uint8List screenshot) async { - if (screenshot.lengthInBytes <= 1024) return false; - - final codec = await ui.instantiateImageCodec(screenshot); - try { - final frame = await codec.getNextFrame(); - try { - if (frame.image.width <= 100 || frame.image.height <= 100) return false; - final data = await frame.image.toByteData( - format: ui.ImageByteFormat.rawRgba, - ); - if (data == null) return false; - - var darkest = 255; - var lightest = 0; - var darkPixels = 0; - final pixelCount = data.lengthInBytes ~/ 4; - final minimumDarkPixels = max(64, pixelCount ~/ 1000); - for (var offset = 0; offset < data.lengthInBytes; offset += 4) { - final red = data.getUint8(offset); - final green = data.getUint8(offset + 1); - final blue = data.getUint8(offset + 2); - final luminance = (299 * red + 587 * green + 114 * blue) ~/ 1000; - if (luminance < darkest) darkest = luminance; - if (luminance > lightest) lightest = luminance; - if (luminance <= _darkPixelLuminance) darkPixels++; - if (lightest - darkest >= _minimumScreenshotContrast && - darkPixels >= minimumDarkPixels) { - return true; - } - } - return false; - } finally { - frame.image.dispose(); - } - } finally { - codec.dispose(); - } -} - -Future _waitFor( - WidgetTester tester, - bool Function() condition, { - required Duration timeout, - required String Function() failure, -}) async { - final deadline = DateTime.now().add(timeout); - while (DateTime.now().isBefore(deadline)) { - await tester.pump(const Duration(milliseconds: 200)); - if (condition()) return; - } - fail(failure()); -} - -String _newUuid() { - final random = Random.secure(); - final bytes = List.generate(16, (_) => random.nextInt(256)); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - final hex = bytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' - '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' - '${hex.substring(20)}'; -} - -class _StripeCheckoutObserver implements AppWebViewObserver { - Uri? uri; - int documentLength = 0; - Uint8List? screenshot; - String? lastFailure; - String? checkoutFailure; - - bool get finished => uri?.host == _stripeHost || checkoutFailure != null; - - String get failureMessage => lastFailure == null - ? 'Stripe Checkout did not load a non-empty document' - : 'Stripe Checkout did not load: $lastFailure'; - - @override - Future onPageLoaded( - Uri uri, { - required int documentLength, - required Future Function() captureScreenshot, - }) async { - if (uri.host != _stripeHost) return; - if (Platform.isMacOS) { - try { - screenshot = await _waitForRenderedScreenshot(captureScreenshot); - } catch (error) { - checkoutFailure = 'Unable to capture WebView screenshot: $error'; - } - } - this.uri = uri; - this.documentLength = documentLength; - } - - @override - void onPageLoadFailed(Uri? uri, String reason) { - lastFailure = '${uri?.host ?? 'unknown host'}: $reason'; - if (uri?.host == _stripeHost) { - checkoutFailure = reason; - } - } -} diff --git a/integration_test/utils/app_robot.dart b/integration_test/utils/app_robot.dart index cefb6a238a..4b60a0609d 100644 --- a/integration_test/utils/app_robot.dart +++ b/integration_test/utils/app_robot.dart @@ -9,8 +9,8 @@ import 'widget_wait_utils.dart'; /// that Firebase Test Lab captures (adb logcat | grep E2E). void e2eLog(String message) => debugPrint('[E2E] $message'); -/// Drives the app shell during integration tests, independent of any feature. -/// Android-only for now; other platforms still use `vpn/vpn_smoke_helpers.dart`. +/// Drives the app shell during Android and desktop integration tests, +/// independent of any feature. class AppRobot { AppRobot(this.tester); @@ -20,6 +20,9 @@ class AppRobot { final Finder onboardingScreen = find.byKey(const Key('onboarding.screen')); final Finder onboardingSkip = find.byKey(const Key('onboarding.skip')); final Finder onboardingPrimary = find.byKey(const Key('onboarding.primary')); + final Finder macosExtensionScreen = find.byKey( + const Key('macos_extension.screen'), + ); /// String-valued widget keys currently in the tree, sorted — a lightweight /// "what's on screen" dump for failure diagnostics. `Key('foo')` is a @@ -244,6 +247,41 @@ class AppRobot { return true; } + /// Dismisses the macOS system-extension screen when it covers home. Payment + /// smokes do not exercise the VPN, so they can safely close this prompt. + Future dismissMacOSExtensionScreenIfShown() async { + if (macosExtensionScreen.evaluate().isEmpty) { + return false; + } + e2eLog('macOS system extension screen shown — dismissing'); + + final close = find + .descendant( + of: macosExtensionScreen, + matching: find.byType(CloseButton), + ) + .hitTestable(); + final tapDeadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(tapDeadline)) { + if (macosExtensionScreen.evaluate().isEmpty) return true; + if (close.evaluate().isNotEmpty) { + await tester.tap(close); + await tester.pump(const Duration(milliseconds: 400)); + break; + } + await tester.pump(const Duration(milliseconds: 200)); + } + + await WidgetWaitUtils.waitForFinderToDisappear( + tester, + macosExtensionScreen, + timeout: const Duration(seconds: 10), + reason: 'macOS system extension screen did not close after dismiss', + ); + e2eLog('macOS system extension screen dismissed'); + return true; + } + /// Waits until [control] is hit-testable — the app-ready gate — clearing /// onboarding that appears while waiting. Home renders before onboarding is /// pushed on top of it, so we first spend up to [onboardingGrace] letting @@ -268,6 +306,10 @@ class AppRobot { await dismissOnboardingIfShown(); continue; } + if (macosExtensionScreen.evaluate().isNotEmpty) { + await dismissMacOSExtensionScreenIfShown(); + continue; + } if (control.hitTestable().evaluate().isNotEmpty) { return; diff --git a/integration_test/utils/payment_robot.dart b/integration_test/utils/payment_robot.dart new file mode 100644 index 0000000000..88e1baf3ef --- /dev/null +++ b/integration_test/utils/payment_robot.dart @@ -0,0 +1,121 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/widgets/app_webview.dart'; +import 'package:lantern/features/plans/provider/plans_notifier.dart'; + +import 'app_robot.dart'; +import 'widget_wait_utils.dart'; + +/// Drives plan selection and the payment-method screen through the same state +/// and controls used by the app. +class PaymentRobot { + PaymentRobot(this.tester, this.app); + + final WidgetTester tester; + final AppRobot app; + + ProviderContainer? _cachedContainer; + + /// Resolves and caches the root provider container before route changes. + ProviderContainer get container { + final cached = _cachedContainer; + if (cached != null) return cached; + + final elements = app.homeScreen.evaluate(); + if (elements.isEmpty) { + fail('Cannot resolve providers: home screen is not mounted'); + } + return _cachedContainer = ProviderScope.containerOf( + elements.first, + listen: false, + ); + } + + /// Loads plans while keeping the auto-disposed provider alive for the test. + Future loadPlans({ + Duration timeout = const Duration(seconds: 60), + }) { + final subscription = container.listen(plansProvider, (_, _) {}); + addTearDown(subscription.close); + return container.read(plansProvider.future).timeout(timeout); + } + + /// Selects the best-value plan, falling back to the first available plan. + Plan selectBestValuePlan(PlansData plans) { + final plan = plans.plans.firstWhere( + (plan) => plan.bestValue, + orElse: () => plans.plans.first, + ); + container.read(plansProvider.notifier).setSelectedPlan(plan); + e2eLog('Selected plan ${plan.id}'); + return plan; + } + + /// Opens the payment-method screen directly for a checkout smoke test. + Future openPaymentMethods({ + required String email, + required AuthFlow authFlow, + AppWebViewObserver? checkoutObserver, + }) { + return appRouter.replaceAll([ + ChoosePaymentMethod( + email: email, + authFlow: authFlow, + checkoutObserver: checkoutObserver, + ), + ]); + } + + /// Expands [provider] when needed and starts its checkout flow. + Future startCheckout({required String provider}) async { + final providerTile = find.byKey(Key('payment.provider.$provider')); + final checkoutButton = find.byKey(Key('payment.checkout.$provider')); + + await app.waitForControlReady( + providerTile, + controlName: '$provider payment method', + onboardingGrace: Duration.zero, + ); + if (checkoutButton.evaluate().isEmpty) { + e2eLog('Expanding the $provider payment method'); + await tester.tap(providerTile); + await tester.pump(const Duration(milliseconds: 300)); + } + await WidgetWaitUtils.waitForFinder( + tester, + checkoutButton, + timeout: const Duration(seconds: 10), + reason: '$provider checkout button was not available', + ); + await tester.ensureVisible(checkoutButton); + await app.waitForControlReady( + checkoutButton, + controlName: '$provider checkout button', + onboardingGrace: Duration.zero, + ); + + e2eLog('Starting $provider checkout'); + await tester.tap(checkoutButton); + } +} + +String e2eEmail([String? runID]) => + 'e2e+${runID ?? newE2ERunID()}@getlantern.org'; + +String newE2ERunID() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' + '${hex.substring(20)}'; +} diff --git a/lib/core/widgets/app_webview.dart b/lib/core/widgets/app_webview.dart index 398699b034..d585e97c93 100644 --- a/lib/core/widgets/app_webview.dart +++ b/lib/core/widgets/app_webview.dart @@ -18,6 +18,7 @@ abstract interface class AppWebViewObserver { Uri uri, { required int documentLength, required Future Function() captureScreenshot, + required Future Function(String source) evaluateJavaScript, }); void onPageLoadFailed(Uri? uri, String reason); @@ -124,7 +125,6 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { shouldOverrideUrlLoading: shouldOverrideUrlLoading, initialUrlRequest: _initialRequest, initialSettings: setting, - onWebViewCreated: (controller) {}, onCreateWindow: (controller, createWindowAction) async { final req = createWindowAction.request; if (PlatformUtils.isWindows) { @@ -207,6 +207,8 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { uri, documentLength: documentLength, captureScreenshot: () => controller.takeScreenshot(), + evaluateJavaScript: (source) => + controller.evaluateJavascript(source: source), ); } catch (error, stackTrace) { appLogger.error('Unable to notify WebView observer', error, stackTrace); diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index ff5acac6d1..c8fd52a66b 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -17,6 +17,8 @@ import 'package:lantern/features/plans/provider/payment_notifier.dart'; import 'package:lantern/features/plans/provider/plans_notifier.dart'; import 'package:lantern/features/plans/provider/referral_notifier.dart'; +const _paymentE2EEnabled = bool.fromEnvironment('PAYMENT_E2E_ENABLED'); + @RoutePage(name: 'ChoosePaymentMethod') class ChoosePaymentMethod extends HookConsumerWidget { final String email; @@ -189,6 +191,22 @@ class ChoosePaymentMethod extends HookConsumerWidget { return; } break; + + // The payment integration test injects this staging-only provider. It + // follows the normal desktop redirect flow so the test exercises the + // same callback and account-refresh behavior as a real checkout without + // creating a charge. + case 'e2e': + if (isDesktop && _paymentE2EEnabled) { + await paymentRedirectFlow( + provider.providers.name, + ref, + context, + paymentRedirectInFlight, + ); + return; + } + break; } } @@ -396,6 +414,7 @@ class ChoosePaymentMethod extends HookConsumerWidget { try { final purchaseResult = await UrlUtils.openWebview( normalizedUrl, + observer: checkoutObserver, ); if (!context.mounted || purchaseResult == null) return; await onPurchaseResult(purchaseResult, context, ref);