diff --git a/.github/scripts/macos_payment_checkout_smoke.sh b/.github/scripts/macos_payment_checkout_smoke.sh index 9ffa35dd45..2d78afff21 100644 --- a/.github/scripts/macos_payment_checkout_smoke.sh +++ b/.github/scripts/macos_payment_checkout_smoke.sh @@ -5,39 +5,11 @@ TEST_PATH="${TEST_PATH:-integration_test/payment/desktop_stripe_checkout_smoke_t ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos-payment-checkout}" LANTERN_DATA_DIR="/Users/Shared/Lantern" LANTERN_LOG_DIR="$LANTERN_DATA_DIR/Logs" -SCREENSHOT_READY_FILE="$LANTERN_DATA_DIR/.checkout-screenshot-ready" -SCREENSHOT_CAPTURED_FILE="$LANTERN_DATA_DIR/.checkout-screenshot-captured" SCREENSHOT_PATH="$ARTIFACT_DIR/stripe-checkout.png" -SCREENSHOT_WATCHER_PID="" - -capture_checkout_screenshot() { - local deadline=$((SECONDS + 300)) - while ((SECONDS < deadline)); do - if [[ -f "$SCREENSHOT_READY_FILE" ]]; then - if /usr/sbin/screencapture -x "$SCREENSHOT_PATH"; then - printf 'Stripe Checkout screenshot captured at %s\n' "$SCREENSHOT_PATH" \ - >"$ARTIFACT_DIR/screenshot-status.txt" - else - printf 'Unable to capture the Stripe Checkout screenshot\n' \ - >"$ARTIFACT_DIR/screenshot-status.txt" - fi - touch "$SCREENSHOT_CAPTURED_FILE" - return - fi - sleep 0.1 - done -} cleanup() { local status=$? - if [[ -n "$SCREENSHOT_WATCHER_PID" ]]; then - kill "$SCREENSHOT_WATCHER_PID" 2>/dev/null || true - wait "$SCREENSHOT_WATCHER_PID" 2>/dev/null || true - fi mkdir -p "$ARTIFACT_DIR" - if [[ ! -f "$SCREENSHOT_PATH" ]]; then - /usr/sbin/screencapture -x "$SCREENSHOT_PATH" 2>/dev/null || true - fi if [[ -d "$LANTERN_LOG_DIR" ]]; then cp -R "$LANTERN_LOG_DIR/." "$ARTIFACT_DIR/" 2>/dev/null || true fi @@ -52,12 +24,15 @@ rm -rf "$LANTERN_DATA_DIR" mkdir -p "$LANTERN_LOG_DIR" mkdir -p "$ARTIFACT_DIR" -capture_checkout_screenshot & -SCREENSHOT_WATCHER_PID=$! - 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 + +if [[ ! -s "$SCREENSHOT_PATH" ]]; then + echo "Stripe Checkout screenshot was not created" >&2 + exit 1 +fi diff --git a/integration_test/payment/desktop_stripe_checkout_smoke_test.dart b/integration_test/payment/desktop_stripe_checkout_smoke_test.dart index b3602bb7f3..d8afaeaff3 100644 --- a/integration_test/payment/desktop_stripe_checkout_smoke_test.dart +++ b/integration_test/payment/desktop_stripe_checkout_smoke_test.dart @@ -1,18 +1,25 @@ +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/utils/storage_utils.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(); @@ -104,7 +111,18 @@ void main() { expect(find.byKey(const ValueKey('app-webview')), findsOneWidget); expect(observer.uri?.host, _stripeHost); expect(observer.documentLength, greaterThan(0)); - await _captureMacOSCheckoutScreenshot(tester); + 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)', @@ -114,19 +132,69 @@ void main() { ); } -Future _captureMacOSCheckoutScreenshot(WidgetTester tester) async { - if (!Platform.isMacOS) return; - - final directory = await AppStorageUtils.getAppDirectory(); - final ready = File('${directory.path}/.checkout-screenshot-ready'); - final captured = File('${directory.path}/.checkout-screenshot-captured'); - await ready.create(recursive: true); - final deadline = DateTime.now().add(const Duration(seconds: 10)); +Future _waitForRenderedScreenshot( + Future Function() captureScreenshot, +) async { + final deadline = DateTime.now().add(_screenshotRenderTimeout); + Object? lastError; while (DateTime.now().isBefore(deadline)) { - if (await captured.exists()) return; - await tester.pump(const Duration(milliseconds: 100)); + 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(); } - debugPrint('Timed out waiting for the macOS checkout screenshot'); } Future _waitFor( @@ -159,6 +227,7 @@ String _newUuid() { class _StripeCheckoutObserver implements AppWebViewObserver { Uri? uri; int documentLength = 0; + Uint8List? screenshot; String? lastFailure; String? checkoutFailure; @@ -169,8 +238,19 @@ class _StripeCheckoutObserver implements AppWebViewObserver { : 'Stripe Checkout did not load: $lastFailure'; @override - void onPageLoaded(Uri uri, {required int documentLength}) { + 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; } diff --git a/lib/core/widgets/app_webview.dart b/lib/core/widgets/app_webview.dart index 5c00ae7e4a..398699b034 100644 --- a/lib/core/widgets/app_webview.dart +++ b/lib/core/widgets/app_webview.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:auto_route/annotations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -11,7 +14,11 @@ final webViewLoadingProvider = NotifierProvider( /// Receives main-frame load events from an in-app WebView. abstract interface class AppWebViewObserver { - void onPageLoaded(Uri uri, {required int documentLength}); + Future onPageLoaded( + Uri uri, { + required int documentLength, + required Future Function() captureScreenshot, + }); void onPageLoadFailed(Uri? uri, String reason); } @@ -182,13 +189,31 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { final documentLength = value is num ? value.toInt() : int.tryParse(value?.toString() ?? '') ?? 0; - observer.onPageLoaded(uri, documentLength: documentLength); + unawaited(_notifyPageLoaded(observer, controller, uri, documentLength)); } catch (error, stackTrace) { appLogger.error('Unable to inspect WebView document', error, stackTrace); observer.onPageLoadFailed(uri, error.toString()); } } + Future _notifyPageLoaded( + AppWebViewObserver observer, + InAppWebViewController controller, + Uri uri, + int documentLength, + ) async { + try { + await observer.onPageLoaded( + uri, + documentLength: documentLength, + captureScreenshot: () => controller.takeScreenshot(), + ); + } catch (error, stackTrace) { + appLogger.error('Unable to notify WebView observer', error, stackTrace); + observer.onPageLoadFailed(uri, error.toString()); + } + } + bool isLanternHost(String host) => host == 'lantern.io' || host == 'www.lantern.io';