Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/scripts/macos_payment_checkout_smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,28 @@ cleanup() {
exit "$status"
}

# Refuse outside CI: this wipes real Lantern app data.
if [[ -z "${CI:-}" ]]; then
echo "Refusing to run outside CI: this wipes $LANTERN_DATA_DIR" >&2
exit 1
fi
Comment thread
jigar-f marked this conversation as resolved.

trap cleanup EXIT

pkill -x Lantern 2>/dev/null || true
rm -rf "$LANTERN_DATA_DIR"
mkdir -p "$LANTERN_LOG_DIR"
mkdir -p "$ARTIFACT_DIR"

# Marker makes the native Radiance setup pick staging (FilePath.isRadianceEnv).
touch "$LANTERN_DATA_DIR/.radiance_env"
Comment thread
jigar-f marked this conversation as resolved.

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_SCREENSHOT_PATH="$SCREENSHOT_PATH"

if [[ ! -s "$SCREENSHOT_PATH" ]]; then
echo "Stripe Checkout screenshot was not created" >&2
Expand Down
22 changes: 22 additions & 0 deletions .github/scripts/macos_smoke_suite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ capture_diagnostics() {
} >"$ARTIFACT_DIR/diagnostics.txt"

capture_command "systemextensionsctl-list" systemextensionsctl list
capture_command "csrutil-status" csrutil status
capture_command "systemextensionsctl-developer" systemextensionsctl developer
capture_command "process-list" ps aux
capture_command "packet-tunnel-processes" pgrep -fl "org.getlantern.lantern.PacketTunnel"
capture_lantern_logs
Expand Down Expand Up @@ -263,6 +265,25 @@ run_with_timeout() {
return "$exit_code"
}

enable_system_extension_developer_mode() {
# The Flutter connect smoke runs from the build directory, and macOS only
# activates system extensions from /Applications unless developer mode is
# on. Idempotent; needs root.
log_step "SIP status: $(csrutil status 2>&1 || true)"
local dev_state
dev_state="$(systemextensionsctl developer 2>&1 || true)"
log_step "System extension developer mode state: $dev_state"
if echo "$dev_state" | grep -qiE 'mode is on|mode: on|enabled'; then
log_step "System extension developer mode already enabled"
return 0
fi
if sudo -n systemextensionsctl developer on 2>/dev/null; then
log_step "Enabled system extension developer mode"
else
printf 'WARNING: could not enable system extension developer mode (needs passwordless sudo). The Flutter connect smoke cannot activate its bundled extension without it. Run once on this runner: sudo systemextensionsctl developer on\n' >&2
fi
Comment thread
jigar-f marked this conversation as resolved.
}

run_system_extension_preflight() {
local app_executable="$1"
local output="$ARTIFACT_DIR/system-extension-preflight.jsonl"
Expand Down Expand Up @@ -348,6 +369,7 @@ if [[ ! -x "$app_executable" ]]; then
fi

if [[ "$RUN_CONNECT_SMOKE" == "true" ]]; then
enable_system_extension_developer_mode
run_system_extension_preflight "$app_executable"
run_flutter_connect_smoke
else
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ jobs:
-RunConnectSmoke:$false `
-RunPaymentCheckoutSmoke

- name: Upload Windows payment smoke diagnostics
if: ${{ always() && inputs.run_payment_checkout_smoke }}
uses: actions/upload-artifact@v4
with:
name: windows-payment-checkout-smoke
path: C:\Users\Public\Lantern\logs
if-no-files-found: ignore
retention-days: 2

Comment thread
jigar-f marked this conversation as resolved.
- name: Sign embedded binaries
if: ${{ !inputs.skip_signing }}
shell: pwsh
Expand Down
195 changes: 76 additions & 119 deletions integration_test/payment/desktop_stripe_checkout_smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ 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;

import '../utils/app_robot.dart';
import '../utils/payment_robot.dart';

const _stripeHost = 'checkout.stripe.com';
const _screenshotPath = String.fromEnvironment('PAYMENT_SMOKE_SCREENSHOT_PATH');
const _screenshotRenderTimeout = Duration(seconds: 30);
Expand All @@ -34,21 +34,11 @@ void main() {
);

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 robot = AppRobot(tester);
final payment = PaymentRobot(tester, robot);
await robot.waitForHomeReady();

final plans = await container
.read(plansProvider.future)
.timeout(const Duration(seconds: 60));
final plans = await payment.loadPlans();
final stripe = plans.providers.desktop.where(
(provider) => provider.providers.name == 'stripe',
);
Expand All @@ -59,79 +49,66 @@ void main() {
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,
payment.selectBestValuePlan(plans);

final checkout = _StripeCheckoutTracker();
final pageEventSubscription = payment.container.listen(
webViewPageEventProvider,
(_, event) {
if (event != null) unawaited(checkout.add(event));
},
);
container.read(plansProvider.notifier).setSelectedPlan(selectedPlan);

final observer = _StripeCheckoutObserver();
await appRouter.replaceAll([
ChoosePaymentMethod(
email: 'e2e+${_newUuid()}@getlantern.org',
authFlow: AuthFlow.renewSubscription,
checkoutObserver: observer,
),
]);
addTearDown(pageEventSubscription.close);

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',
await payment.openPaymentMethods(
email: e2eEmail(),
authFlow: AuthFlow.renewSubscription,
);
await payment.startStripeCheckout();

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 _waitForCheckout(tester, checkout);

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}');
if (checkout.failure != null) {
fail('Stripe Checkout failed to load: ${checkout.failure}');
}
expect(find.byKey(const ValueKey('app-webview')), findsOneWidget);
expect(observer.uri?.host, _stripeHost);
expect(observer.documentLength, greaterThan(0));
expect(checkout.uri?.host, _stripeHost);
expect(checkout.documentLength, greaterThan(0));
if (Platform.isMacOS) {
final screenshot = observer.screenshot;
final screenshot = checkout.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}');
e2eLog('Stripe Checkout screenshot saved to ${file.path}');
}
}
debugPrint(
e2eLog(
'Stripe Checkout rendered from $_stripeHost '
'(${observer.documentLength} document characters)',
'(${checkout.documentLength} document characters)',
);
},
timeout: const Timeout(Duration(minutes: 5)),
);
}

/// Waits until the tracker sees Stripe load or fail — a state wait, not a
/// widget wait, so the robot's finder-based helpers don't apply.
Future<void> _waitForCheckout(
WidgetTester tester,
_StripeCheckoutTracker checkout,
) async {
final deadline = DateTime.now().add(const Duration(minutes: 3));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 200));
if (checkout.finished) return;
}
fail(checkout.failureMessage);
}

Future<Uint8List> _waitForRenderedScreenshot(
Future<Uint8List?> Function() captureScreenshot,
) async {
Expand Down Expand Up @@ -197,69 +174,49 @@ Future<bool> _hasVisibleContent(Uint8List screenshot) async {
}
}

Future<void> _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<int>.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 {
/// Folds [webViewPageEventProvider] events into a checkout verdict.
class _StripeCheckoutTracker {
Uri? uri;
int documentLength = 0;
Uint8List? screenshot;
String? lastFailure;
String? checkoutFailure;
String? _terminalFailure;

bool get finished => uri?.host == _stripeHost || checkoutFailure != null;
bool get loaded => uri?.host == _stripeHost;

bool get finished => loaded || _terminalFailure != null;

/// Non-null once checkout can no longer succeed.
String? get failure => _terminalFailure;

String get failureMessage => lastFailure == null
? 'Stripe Checkout did not load a non-empty document'
: 'Stripe Checkout did not load: $lastFailure';

@override
Future<void> onPageLoaded(
Uri uri, {
required int documentLength,
required Future<Uint8List?> 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;
Future<void> add(WebViewPageEvent event) async {
switch (event) {
case WebViewPageLoaded():
if (event.uri?.host != _stripeHost) return;
if (Platform.isMacOS && screenshot == null) {
try {
screenshot = await _waitForRenderedScreenshot(
event.captureScreenshot,
);
} catch (error) {
_terminalFailure = 'Unable to capture WebView screenshot: $error';
return;
}
}
uri = event.uri;
documentLength = event.documentLength;
case WebViewPageLoadFailed():
lastFailure = '${event.uri?.host ?? 'unknown host'}: ${event.reason}';
// Only a main-frame failure on the Stripe host itself is terminal —
// intermediate redirect hosts can fail benignly before Checkout
// loads. Other failures are kept as context for the timeout message.
if (event.uri?.host == _stripeHost) {
_terminalFailure = lastFailure;
}
}
}
}
Loading
Loading