diff --git a/.github/scripts/macos_smoke_suite.sh b/.github/scripts/macos_smoke_suite.sh index 636363149a..e456c98fc3 100755 --- a/.github/scripts/macos_smoke_suite.sh +++ b/.github/scripts/macos_smoke_suite.sh @@ -4,8 +4,7 @@ set -euo pipefail TEST_PATH="${TEST_PATH:-integration_test/vpn/macos_connect_smoke_test.dart}" ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos}" RUN_CONNECT_SMOKE="${RUN_CONNECT_SMOKE:-true}" -ENABLE_IP_CHECK="${ENABLE_IP_CHECK:-false}" -FORCE_FULL_TUNNEL="${FORCE_FULL_TUNNEL:-true}" +VPN_LIFECYCLE_SMOKE="${VPN_LIFECYCLE_SMOKE:-false}" EXTENSION_TIMEOUT_SECONDS="${EXTENSION_TIMEOUT_SECONDS:-120}" APP_INSTALL_DIR="${APP_INSTALL_DIR:-/Applications/Lantern.app}" LANTERN_LOG_DIR="${LANTERN_LOG_DIR:-/Users/Shared/Lantern/Logs}" @@ -152,6 +151,24 @@ resolve_app_path() { return 1 } +register_installed_app() { + local app_path="$1" + local registry="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + local mode bundle + + # Build and XCTest copies share Lantern's bundle ID. macOS VPN approval must + # resolve the installed fixture, even after those temporary copies are deleted. + for mode in Debug Profile Release; do + "$registry" -u "$PWD/build/macos/Build/Products/$mode/Lantern.app" 2>/dev/null || true + done + for bundle in "$HOME"/Library/Developer/Xcode/DerivedData/Runner-*/Build/Products/*/Lantern.app; do + [[ -d "$bundle" ]] || continue + "$registry" -u "$bundle" 2>/dev/null || true + done + log_step "Registering installed Lantern app at $app_path" + "$registry" -f "$app_path" +} + capture_command() { local name="$1" shift @@ -181,9 +198,10 @@ reset_lantern_logs() { capture_unified_logs() { log_step "Capturing unified logs" log show \ - --last 30m \ + --last 10m \ + --info --debug \ --style syslog \ - --predicate 'subsystem == "org.getlantern.lantern" OR subsystem == "org.getlantern.lantern.PacketTunnel"' \ + --predicate 'subsystem == "org.getlantern.lantern" OR subsystem == "org.getlantern.lantern.PacketTunnel" OR process == "neagent" OR process == "nehelper"' \ >"$ARTIFACT_DIR/unified-lantern.log" 2>&1 || true } @@ -202,12 +220,18 @@ capture_diagnostics() { date } >"$ARTIFACT_DIR/diagnostics.txt" + capture_screenshot capture_command "systemextensionsctl-list" systemextensionsctl list + capture_command "vpn-profiles" scutil --nc list capture_command "process-list" ps aux capture_command "packet-tunnel-processes" pgrep -fl "org.getlantern.lantern.PacketTunnel" + capture_command "interfaces" ifconfig + capture_command "routes" netstat -rn + if [[ "$VPN_LIFECYCLE_SMOKE" == "true" ]]; then + cp /Users/Shared/Lantern/E2E/vpn-smoke-*.json "$ARTIFACT_DIR/" 2>/dev/null || true + fi capture_lantern_logs capture_unified_logs - capture_screenshot } quit_lantern() { @@ -301,23 +325,19 @@ run_system_extension_preflight() { } run_flutter_connect_smoke() { + local app_path="$1" local args=( - "test" - "$TEST_PATH" + "drive" + "--profile" + "--use-application-binary=$app_path" + "--keep-app-running" + "--driver=test_driver/integration_test.dart" + "--target=$TEST_PATH" "-d" "macos" - "--reporter=expanded" - "--dart-define=DISABLE_SYSTEM_TRAY=true" ) - if [[ "$ENABLE_IP_CHECK" == "true" ]]; then - args+=("--dart-define=ENABLE_IP_CHECK=true") - fi - - if [[ "$FORCE_FULL_TUNNEL" == "true" ]]; then - args+=("--dart-define=SMOKE_FORCE_FULL_TUNNEL=true") - fi - + # Smoke options are compiled into the signed fixture before it is installed. log_step "Running macOS connect smoke: flutter ${args[*]}" flutter "${args[@]}" } @@ -325,10 +345,16 @@ run_flutter_connect_smoke() { on_exit() { local status=$? - quit_lantern if [[ "$status" -ne 0 ]]; then + # Preserve native permission prompts in the failure screenshot. capture_diagnostics "failure" fi + quit_lantern + if [[ "$VPN_LIFECYCLE_SMOKE" == "true" ]]; then + rm -f /Users/Shared/Lantern/E2E/vpn-smoke-request.json \ + /Users/Shared/Lantern/E2E/vpn-smoke-request.json.tmp \ + /Users/Shared/Lantern/E2E/vpn-smoke-result.json + fi detach_dmg exit "$status" @@ -339,6 +365,10 @@ trap on_exit EXIT mkdir -p "$ARTIFACT_DIR" reset_lantern_logs capture_command "systemextensionsctl-list-initial" systemextensionsctl list +if [[ "$VPN_LIFECYCLE_SMOKE" == "true" ]]; then + rm -f /Users/Shared/Lantern/E2E/vpn-smoke-request.json \ + /Users/Shared/Lantern/E2E/vpn-smoke-result.json +fi app_path="$(resolve_app_path)" app_executable="$app_path/Contents/MacOS/Lantern" @@ -348,8 +378,9 @@ if [[ ! -x "$app_executable" ]]; then fi if [[ "$RUN_CONNECT_SMOKE" == "true" ]]; then + register_installed_app "$app_path" run_system_extension_preflight "$app_executable" - run_flutter_connect_smoke + run_flutter_connect_smoke "$app_path" else log_step "Skipping macOS connect smoke test." fi diff --git a/.github/workflows/app-smoke-tests.yml b/.github/workflows/app-smoke-tests.yml index 7bb67c6c41..101f2c17d6 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 + - vpn-lifecycle # macOS: reconnects, forced descriptor fallback, and failed-start recovery - auth-smoke # auth flows only (sign-in/up, recovery, deletion) against the smoke roster - auto-update # desktop: install a lower signed fixture and update it from the isolated staging feed - payment-smoke # Stripe Checkout rendering and payment-to-Pro conversion @@ -53,6 +54,11 @@ jobs: validate-inputs: runs-on: ubuntu-latest steps: + - name: Validate VPN lifecycle selection + if: ${{ inputs.tests == 'vpn-lifecycle' && inputs.platforms != 'macos' }} + run: | + echo '::error title=Invalid smoke selection::vpn-lifecycle requires platforms=macos' + exit 2 - name: Validate auto-update selection if: ${{ inputs.tests == 'auto-update' && !contains(fromJSON('["all", "macos", "windows"]'), inputs.platforms) }} shell: bash @@ -149,6 +155,8 @@ jobs: build_type: nightly installer_base_name: ${{ needs.prepare.outputs.installer_base_name }} runner_label: lantern-macos-smoke + vpn_lifecycle_smoke: ${{ inputs.tests == 'vpn-lifecycle' }} + enable_ip_check: true run_connect_smoke: ${{ !contains(fromJSON('["payment-smoke", "auth-smoke"]'), inputs.tests || 'all') }} run_payment_smoke: ${{ contains(fromJSON('["all", "payment-smoke"]'), inputs.tests || 'all') }} run_auth_smoke: ${{ contains(fromJSON('["all", "auth-smoke"]'), inputs.tests || 'all') }} diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 881e829645..c4dd9425de 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -83,6 +83,11 @@ on: required: false type: boolean default: false + vpn_lifecycle_smoke: + description: "Build a test-only extension for reconnect and failed-start smoke tests" + required: false + type: boolean + default: false run_auth_smoke: description: "Run macOS auth smoke integration tests" required: false @@ -168,6 +173,24 @@ jobs: } fi + - name: Enable VPN lifecycle test fixture + if: ${{ inputs.vpn_lifecycle_smoke }} + shell: bash + env: + RUNNER_LABEL: ${{ inputs.runner_label }} + RUN_CONNECT_SMOKE: ${{ inputs.run_connect_smoke }} + SECRETLESS_TEST_BUILD: ${{ inputs.secretless_test_build }} + run: | + set -euo pipefail + [[ "$RUNNER_LABEL" == "lantern-macos-smoke" && "$RUN_CONNECT_SMOKE" == "true" && + "$BUILD_TYPE" == "nightly" && "$SECRETLESS_TEST_BUILD" == "false" ]] || { + echo "VPN fault injection is restricted to the signed macOS smoke fixture." >&2 + exit 1 + } + printf '%s\n' 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) VPN_SMOKE_TEST' \ + > "$RUNNER_TEMP/vpn-smoke.xcconfig" + echo "XCODE_XCCONFIG_FILE=$RUNNER_TEMP/vpn-smoke.xcconfig" >> "$GITHUB_ENV" + - name: Download pubspec.yaml uses: actions/download-artifact@v4 with: @@ -363,8 +386,13 @@ jobs: env: BUILD_TYPE: ${{ inputs.build_type }} AUTO_UPDATE_E2E: ${{ inputs.auto_update_e2e }} - FLUTTER_BUILD_MODE: ${{ inputs.flutter_build_mode }} - FLUTTER_TARGET: ${{ inputs.flutter_target }} + # Drive the installed fixture; rebuilding a debug test app changes its extension hash. + FLUTTER_BUILD_MODE: ${{ inputs.run_connect_smoke && 'profile' || inputs.flutter_build_mode }} + FLUTTER_TARGET: ${{ inputs.run_connect_smoke && 'integration_test/vpn/macos_connect_smoke_test.dart' || inputs.flutter_target }} + RUN_CONNECT_SMOKE: ${{ inputs.run_connect_smoke }} + ENABLE_IP_CHECK: ${{ inputs.enable_ip_check }} + FORCE_FULL_TUNNEL: ${{ inputs.force_full_tunnel_smoke }} + VPN_LIFECYCLE_SMOKE: ${{ inputs.vpn_lifecycle_smoke }} VERSION: ${{ inputs.version }} INSTALLER_NAME: ${{ inputs.installer_base_name }} GOMOBILECACHE: ${{ env.GOMOBILECACHE }} @@ -404,14 +432,14 @@ jobs: - name: Upload macOS app uses: actions/upload-artifact@v4 with: - name: lantern-macos-app + name: ${{ inputs.vpn_lifecycle_smoke && 'lantern-macos-vpn-smoke-app' || 'lantern-macos-app' }} path: ${{ runner.temp }}/Lantern.app.zip retention-days: 2 - name: Upload macOS installer uses: actions/upload-artifact@v4 with: - name: lantern-installer-dmg + name: ${{ inputs.vpn_lifecycle_smoke && 'lantern-macos-vpn-smoke-dmg' || 'lantern-installer-dmg' }} path: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg retention-days: 2 @@ -425,15 +453,14 @@ jobs: - name: Run macOS connect smoke if: ${{ inputs.run_connect_smoke }} - timeout-minutes: 30 + timeout-minutes: ${{ inputs.vpn_lifecycle_smoke && 60 || 30 }} shell: bash env: ARTIFACT_DIR: ${{ runner.temp }}/macos-smoke-artifacts DMG_ARTIFACT_DIR: ${{ github.workspace }} - ENABLE_IP_CHECK: ${{ inputs.enable_ip_check }} - FORCE_FULL_TUNNEL: ${{ inputs.force_full_tunnel_smoke }} EXTENSION_TIMEOUT_SECONDS: ${{ inputs.extension_timeout_seconds }} RUN_CONNECT_SMOKE: true + VPN_LIFECYCLE_SMOKE: ${{ inputs.vpn_lifecycle_smoke }} run: ./.github/scripts/macos_smoke_suite.sh - name: Upload macOS smoke diagnostics diff --git a/Makefile b/Makefile index 0f379387f5..0060cedffb 100644 --- a/Makefile +++ b/Makefile @@ -290,6 +290,7 @@ APPDMG := $(call get-command,appdmg) AUTO_UPDATE_E2E_DART_DEFINE := $(if $(filter true 1 yes,$(AUTO_UPDATE_E2E)),--dart-define=AUTO_UPDATE_E2E=true,) DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) $(if $(RADIANCE_ENV),--dart-define=RADIANCE_ENV=$(RADIANCE_ENV),) $(AUTO_UPDATE_E2E_DART_DEFINE) FLUTTER_TARGET_ARG := $(if $(FLUTTER_TARGET),--target=$(FLUTTER_TARGET),) +MACOS_CONNECT_SMOKE_DEFINES := $(if $(filter true,$(RUN_CONNECT_SMOKE)),--dart-define=DISABLE_SYSTEM_TRAY=true --dart-define=ENABLE_IP_CHECK=$(ENABLE_IP_CHECK) --dart-define=SMOKE_FORCE_FULL_TUNNEL=$(FORCE_FULL_TUNNEL) --dart-define=VPN_LIFECYCLE_SMOKE=$(VPN_LIFECYCLE_SMOKE),) STEALTH_NOVPN_BUILD_VARS := BUILD_TYPE=stealth-novpn STEALTH_MODE=stealth-novpn STEALTH_LEAKAGE_MODE=stealth-novpn STEALTH_VPN_BUILD_VARS := BUILD_TYPE=stealth-vpn STEALTH_MODE=stealth-vpn STEALTH_LEAKAGE_MODE=stealth-vpn STEALTH_ICON_SEED ?= @@ -550,7 +551,7 @@ build-macos-release: $(DARWIN_RELEASE_BUILD) $(DARWIN_PROFILE_BUILD): $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (profile) for macOS..." rm -vf $(MACOS_INSTALLER) - flutter build macos --profile $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + flutter build macos --profile $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) $(MACOS_CONNECT_SMOKE_DEFINES) .PHONY: build-macos-release build-macos-profile stage-macos-profile build-macos-profile: $(DARWIN_PROFILE_BUILD) diff --git a/integration_test/vpn/connect_smoke_harness.dart b/integration_test/vpn/connect_smoke_harness.dart index 96e26a334d..db9ba487a3 100644 --- a/integration_test/vpn/connect_smoke_harness.dart +++ b/integration_test/vpn/connect_smoke_harness.dart @@ -75,7 +75,8 @@ Future _fetchPublicIpOnce() async { final body = await response .transform(const SystemEncoding().decoder) - .join(); + .join() + .timeout(const Duration(seconds: 6)); final ip = body.trim(); if (ip.isNotEmpty && InternetAddress.tryParse(ip) != null) { return ip; @@ -88,7 +89,7 @@ Future _fetchPublicIpOnce() async { return null; } -Future _fetchPublicIpWithRetry({ +Future fetchPublicIpForSmoke({ required Duration timeout, required String reason, }) async { @@ -104,6 +105,15 @@ Future _fetchPublicIpWithRetry({ fail('Failed to fetch public IP: $reason'); } +Future expectPublicIpRestored(String baselineIp) async { + final deadline = DateTime.now().add(const Duration(seconds: 60)); + while (DateTime.now().isBefore(deadline)) { + if (await _fetchPublicIpOnce() == baselineIp) return; + await Future.delayed(const Duration(seconds: 2)); + } + fail('Public IP did not return to its pre-connect value'); +} + Future _didPublicIpChangeFromBaseline(String baselineIp) async { final deadline = DateTime.now().add(const Duration(seconds: 60)); while (DateTime.now().isBefore(deadline)) { @@ -117,7 +127,7 @@ Future _didPublicIpChangeFromBaseline(String baselineIp) async { return false; } -Future _disconnectVpn( +Future disconnectVpnForSmoke( WidgetTester tester, { required Finder vpnToggle, required VpnStateFinders vpnStateFinders, @@ -143,6 +153,8 @@ Future runConnectSmokeHarness( WidgetTester tester, { bool enableIpCheck = false, bool requireTrafficAfterConnect = false, + bool requireIpRestored = false, + Future Function()? afterConnect, }) async { final finders = VpnSmokeFinders(); final vpnStateFinders = VpnStateFinders(textLabels: _vpnStateLabels); @@ -156,9 +168,9 @@ Future runConnectSmokeHarness( ); await _setRoutingModeToFullTunnelForSmoke(tester, finders: finders); - if (enableIpCheck) { + if (enableIpCheck || requireIpRestored) { debugPrint('IP check: enabled; fetching baseline before connect'); - baselinePublicIp = await _fetchPublicIpWithRetry( + baselinePublicIp = await fetchPublicIpForSmoke( timeout: const Duration(seconds: 40), reason: 'before connect', ); @@ -174,11 +186,12 @@ Future runConnectSmokeHarness( expected: const [VPNStatus.connected], timeout: const Duration(seconds: 45), reason: 'VPN did not reach connected state within 45 seconds', + allowVpnConflict: true, ); if (requireTrafficAfterConnect) { debugPrint('IP check: confirming public traffic after connect'); - await _fetchPublicIpWithRetry( + await fetchPublicIpForSmoke( timeout: const Duration(seconds: 45), reason: 'after connect', ); @@ -192,14 +205,19 @@ Future runConnectSmokeHarness( debugPrint('IP check: passed'); } } + await afterConnect?.call(); } finally { - await _disconnectVpn( + await disconnectVpnForSmoke( tester, vpnToggle: finders.vpnToggle, vpnStateFinders: vpnStateFinders, ); } + if (requireIpRestored && baselinePublicIp != null) { + await expectPublicIpRestored(baselinePublicIp); + } + if (enableIpCheck && baselinePublicIp != null && !ipChanged) { fail( 'Public IP did not change after VPN connected (baseline: $baselinePublicIp)', diff --git a/integration_test/vpn/macos_connect_smoke_test.dart b/integration_test/vpn/macos_connect_smoke_test.dart index 175b09abfe..c0f3be7b9e 100644 --- a/integration_test/vpn/macos_connect_smoke_test.dart +++ b/integration_test/vpn/macos_connect_smoke_test.dart @@ -1,7 +1,11 @@ +import 'dart:convert'; +import 'dart:io'; + 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/app_eum.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/features/macos_extension/provider/macos_extension_notifier.dart'; import 'package:lantern/main.dart' as app; @@ -14,6 +18,7 @@ const _enableIpCheck = bool.fromEnvironment( 'ENABLE_IP_CHECK', defaultValue: false, ); +const _lifecycleSmoke = bool.fromEnvironment('VPN_LIFECYCLE_SMOKE'); const _extensionBlockingStatuses = { SystemExtensionStatus.requiresApproval, @@ -25,15 +30,113 @@ const _extensionBlockingStatuses = { void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('macOS VPN connect/disconnect smoke', (tester) async { - await app.main(); - await _requireSystemExtensionReady(tester); - await runConnectSmokeHarness( + testWidgets( + 'macOS VPN connect/disconnect smoke', + (tester) async { + await app.main(); + await _requireSystemExtensionReady(tester); + if (_lifecycleSmoke) { + await _runLifecycleSmoke(tester); + } else { + await runConnectSmokeHarness( + tester, + enableIpCheck: _enableIpCheck, + requireTrafficAfterConnect: true, + ); + } + }, + timeout: const Timeout(Duration(minutes: _lifecycleSmoke ? 45 : 15)), + ); +} + +Future _runLifecycleSmoke(WidgetTester tester) async { + final directory = Directory('/Users/Shared/Lantern/E2E'); + await directory.create(recursive: true); + final request = File('${directory.path}/vpn-smoke-request.json'); + final result = File('${directory.path}/vpn-smoke-result.json'); + + Future clearRequest() async { + for (final file in [request, result]) { + if (await file.exists()) await file.delete(); + } + } + + Future setRequest(String action) async { + await clearRequest(); + final id = DateTime.now().microsecondsSinceEpoch.toString(); + final temporary = File('${request.path}.tmp'); + await temporary.writeAsString(jsonEncode({'id': id, 'action': action})); + await temporary.rename(request.path); + return id; + } + + Future> waitForResult(String id, String stage) async { + final deadline = DateTime.now().add(const Duration(seconds: 60)); + while (DateTime.now().isBefore(deadline)) { + await confirmVpnConflictForSmoke(tester); + if (await result.exists()) { + final receipt = + jsonDecode(await result.readAsString()) as Map; + if (receipt['id'] == id && receipt['stage'] == stage) { + debugPrint('macOS VPN smoke: $receipt'); + return receipt; + } + } + await tester.pump(const Duration(milliseconds: 200)); + } + fail('The installed extension did not report $stage for attempt $id'); + } + + await clearRequest(); + try { + // Keep the app alive across cycles so reconnects exercise the same client. + for (var cycle = 0; cycle < 3; cycle++) { + debugPrint('macOS VPN smoke: normal cycle ${cycle + 1}/3'); + await runConnectSmokeHarness( + tester, + enableIpCheck: true, + requireIpRestored: true, + ); + } + + final baseline = await fetchPublicIpForSmoke( + timeout: const Duration(seconds: 40), + reason: 'before injected failure', + ); + final failedAttempt = await setRequest('failAfterSettings'); + await tester.tap(VpnSmokeFinders().vpnToggle); + await tester.pump(const Duration(milliseconds: 200)); + await waitForResult(failedAttempt, 'failed-after-settings'); + await VpnStateFinders().waitFor( tester, - enableIpCheck: _enableIpCheck, - requireTrafficAfterConnect: true, + expected: const [VPNStatus.disconnected], + timeout: const Duration(seconds: 45), + reason: 'The failed tunnel did not disconnect', ); - }); + await expectPublicIpRestored(baseline); + + for (var cycle = 0; cycle < 3; cycle++) { + debugPrint('macOS VPN smoke: fallback cycle ${cycle + 1}/3'); + final id = await setRequest('fallback'); + await runConnectSmokeHarness( + tester, + enableIpCheck: true, + requireIpRestored: true, + afterConnect: () async { + final receipt = await waitForResult(id, 'fallback'); + expect(receipt['interface'], matches(r'^utun\d+$')); + expect(receipt['addresses'], isNotEmpty); + }, + ); + } + } finally { + await clearRequest(); + await disconnectVpnForSmoke( + tester, + vpnToggle: VpnSmokeFinders().vpnToggle, + vpnStateFinders: VpnStateFinders(), + ); + } } Future _requireSystemExtensionReady(WidgetTester tester) async { diff --git a/integration_test/vpn/vpn_smoke_helpers.dart b/integration_test/vpn/vpn_smoke_helpers.dart index 42af574684..92c3aa2d89 100644 --- a/integration_test/vpn/vpn_smoke_helpers.dart +++ b/integration_test/vpn/vpn_smoke_helpers.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lantern/core/common/app_eum.dart'; +import 'package:lantern/core/localization/i18n.dart'; import '../utils/widget_wait_utils.dart'; @@ -58,10 +59,12 @@ class VpnStateFinders { WidgetTester tester, { required List expected, required Duration timeout, + bool allowVpnConflict = false, }) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 200)); + if (allowVpnConflict) await confirmVpnConflictForSmoke(tester); final state = current(); if (expected.contains(state)) { return state; @@ -75,11 +78,13 @@ class VpnStateFinders { required List expected, required Duration timeout, required String reason, + bool allowVpnConflict = false, }) async { final state = await tryWaitFor( tester, expected: expected, timeout: timeout, + allowVpnConflict: allowVpnConflict, ); if (state != null) { return state; @@ -88,6 +93,21 @@ class VpnStateFinders { } } +Future confirmVpnConflictForSmoke(WidgetTester tester) async { + final connectAnyway = find + .descendant( + of: find.byType(AlertDialog), + matching: find.text('vpn_conflict_connect_anyway'.i18n), + ) + .hitTestable(); + if (connectAnyway.evaluate().isEmpty) return; + + // CI runners may use Tailscale for management. Follow the normal UI override. + debugPrint('VPN smoke: confirming Connect anyway in the VPN conflict dialog'); + await tester.tap(connectAnyway); + await tester.pump(const Duration(milliseconds: 200)); +} + String buildVpnDebugSnapshot( WidgetTester tester, VpnStateFinders vpnStateFinders, diff --git a/macos/PacketTunnel/SingBox/ExtensionPlatformInterface.swift b/macos/PacketTunnel/SingBox/ExtensionPlatformInterface.swift index c0960b10cc..831b22913a 100644 --- a/macos/PacketTunnel/SingBox/ExtensionPlatformInterface.swift +++ b/macos/PacketTunnel/SingBox/ExtensionPlatformInterface.swift @@ -77,6 +77,30 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco //let excludeAPNs = await SharedPreferences.excludeAPNsRoute.get() // Base network settings let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + // The fallback identifies the utun by its addresses, even without auto-routing. + // Apply the addresses here; routes and DNS remain conditional below. + var ipv4Address: [String] = [] + var ipv4Mask: [String] = [] + let ipv4AddressIterator = options.getInet4Address()! + appLogger.info("iterating over ipv4 addresses") + while ipv4AddressIterator.hasNext() { + let ipv4Prefix = ipv4AddressIterator.next()! + ipv4Address.append(ipv4Prefix.address()) + ipv4Mask.append(ipv4Prefix.mask()) + } + let ipv4Settings = NEIPv4Settings(addresses: ipv4Address, subnetMasks: ipv4Mask) + + var ipv6Address: [String] = [] + var ipv6Prefixes: [NSNumber] = [] + let ipv6AddressIterator = options.getInet6Address()! + while ipv6AddressIterator.hasNext() { + let ipv6Prefix = ipv6AddressIterator.next()! + ipv6Address.append(ipv6Prefix.address()) + ipv6Prefixes.append(NSNumber(value: ipv6Prefix.prefix())) + } + let ipv6Settings = NEIPv6Settings( + addresses: ipv6Address, networkPrefixLengths: ipv6Prefixes) + appLogger.info("Checking auto route") if options.getAutoRoute() { settings.mtu = NSNumber(value: options.getMTU()) @@ -86,17 +110,6 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco dnsSettings.matchDomainsNoSearch = true settings.dnsSettings = dnsSettings - var ipv4Address: [String] = [] - var ipv4Mask: [String] = [] - let ipv4AddressIterator = options.getInet4Address()! - appLogger.info("iterating over ipv4 addresses") - while ipv4AddressIterator.hasNext() { - let ipv4Prefix = ipv4AddressIterator.next()! - ipv4Address.append(ipv4Prefix.address()) - ipv4Mask.append(ipv4Prefix.mask()) - } - - let ipv4Settings = NEIPv4Settings(addresses: ipv4Address, subnetMasks: ipv4Mask) var ipv4Routes: [NEIPv4Route] = [] var ipv4ExcludeRoutes: [NEIPv4Route] = [] @@ -161,18 +174,6 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco ipv4Settings.includedRoutes = ipv4Routes ipv4Settings.excludedRoutes = ipv4ExcludeRoutes - settings.ipv4Settings = ipv4Settings - - var ipv6Address: [String] = [] - var ipv6Prefixes: [NSNumber] = [] - let ipv6AddressIterator = options.getInet6Address()! - while ipv6AddressIterator.hasNext() { - let ipv6Prefix = ipv6AddressIterator.next()! - ipv6Address.append(ipv6Prefix.address()) - ipv6Prefixes.append(NSNumber(value: ipv6Prefix.prefix())) - } - let ipv6Settings = NEIPv6Settings( - addresses: ipv6Address, networkPrefixLengths: ipv6Prefixes) var ipv6Routes: [NEIPv6Route] = [] var ipv6ExcludeRoutes: [NEIPv6Route] = [] @@ -209,8 +210,9 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco ipv6Settings.includedRoutes = ipv6Routes ipv6Settings.excludedRoutes = ipv6ExcludeRoutes - settings.ipv6Settings = ipv6Settings } + settings.ipv4Settings = ipv4Settings + settings.ipv6Settings = ipv6Settings appLogger.info("Checking if HTTP proxy is enabled...") if options.isHTTPProxyEnabled() { @@ -255,25 +257,37 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco appLogger.info("Setting tunnel network settings to \(settings)...") try applyNetworkSettings(settings) + #if VPN_SMOKE_TEST + let smokeRequest = try VPNSmokeRequest.load() + if smokeRequest?.action == .failAfterSettings { + try smokeRequest?.record(stage: "failed-after-settings") + throw NSError( + domain: "VPNSmokeTest", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Injected failure after applying tunnel settings"]) + } + let forceFallback = smokeRequest?.action == .fallback + #else + let forceFallback = false + #endif + appLogger.info("Accessing the socket file descriptor...") - if let tunFd = tunnel.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32 { + if !forceFallback, + let tunFd = tunnel.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32 + { ret0_.pointee = tunFd appLogger.info("Returning tunnel file descriptor \(tunFd)") return } - // This fallback carries every tunnel on macOS 12, where the KVC path above - // never resolves. It scans the process for the lowest-numbered utun, so it is - // only correct while exactly one is open — which is what the teardown guard in - // ExtensionProvider.startTunnel exists to ensure. Logged so a recurrence can be - // told apart from a stale-fd selection in a user's logs. - appLogger.info("Accessing tunnel file descriptor from C loop...") - let tunFdFromLoop = LibboxGetTunnelFileDescriptor() - guard tunFdFromLoop != -1 else { - throw NSError(domain: "Missing TUN FD", code: 0) - } - appLogger.info("Returning tunnel file descriptor \(tunFdFromLoop) (from C loop)") - ret0_.pointee = tunFdFromLoop + let addresses = + (settings.ipv4Settings?.addresses ?? []) + (settings.ipv6Settings?.addresses ?? []) + let candidate = try TunnelFileDescriptor.resolve(addresses: addresses) + appLogger.info( + "Returning tunnel file descriptor \(candidate.descriptor) (\(candidate.interfaceName))") + #if VPN_SMOKE_TEST + try smokeRequest?.record(stage: "fallback", candidate: candidate) + #endif + ret0_.pointee = candidate.descriptor } public func usePlatformAutoDetectControl() -> Bool { @@ -482,6 +496,8 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco func reset() { networkSettings = nil + nwMonitor?.cancel() + nwMonitor = nil } public func restartService() throws { @@ -531,3 +547,35 @@ public class ExtensionPlatformInterface: NSObject, UtilsPlatformInterfaceProtoco } } +#if VPN_SMOKE_TEST + // Only the dedicated CI fixture reads these files; normal builds omit this code. + private struct VPNSmokeRequest: Decodable { + enum Action: String, Decodable { + case fallback + case failAfterSettings + } + + let id: String + let action: Action + private static let directory = FilePath.dataDirectory.appendingPathComponent("E2E") + + static func load() throws -> Self? { + let url = directory.appendingPathComponent("vpn-smoke-request.json") + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try JSONDecoder().decode(Self.self, from: Data(contentsOf: url)) + } + + func record(stage: String, candidate: TunnelFileDescriptor.Candidate? = nil) throws { + var result: [String: Any] = [ + "id": id, "stage": stage, "pid": ProcessInfo.processInfo.processIdentifier, + ] + if let candidate { + result["interface"] = candidate.interfaceName + result["addresses"] = candidate.addresses + } + try JSONSerialization.data(withJSONObject: result).write( + to: Self.directory.appendingPathComponent("vpn-smoke-result.json"), options: .atomic) + appLogger.info("VPN smoke: \(id) \(stage)") + } + } +#endif diff --git a/macos/PacketTunnel/SingBox/ExtensionProvider.swift b/macos/PacketTunnel/SingBox/ExtensionProvider.swift index 38bdf54160..11dbf0b091 100644 --- a/macos/PacketTunnel/SingBox/ExtensionProvider.swift +++ b/macos/PacketTunnel/SingBox/ExtensionProvider.swift @@ -55,20 +55,11 @@ public class ExtensionProvider: NEPacketTunnelProvider { platformInterface = ExtensionPlatformInterface(self) } - // A start can arrive while the previous tunnel is still up: the extension - // process outlives the app, so a force-quit without disconnecting — or an - // app-side stop that no-ops on a stale NEVPNStatus — leaves it running. - // Starting on top leaves the old utun open, and openTun's fallback then - // hands the new sing-box the lowest-numbered utun in the process (the dead - // one) while the system routes traffic to the new interface. Every packet - // is blackholed until the extension process is killed, which is why - // reporters find that only a reboot fixes it (getlantern/engineering#3781). - // - // Claimed before the bring-up rather than after: a start that fails partway - // can still have opened a utun. + // The extension outlives the app. Replacing a session must also clean up a + // previous start that failed after applying network settings. if claimTunnel() { appLogger.info("(lantern-tunnel) start arrived with a live tunnel; stopping it first") - stopService() + try stopService() } // Start the IPC server before any VPN operations @@ -177,12 +168,15 @@ public class ExtensionProvider: NEPacketTunnelProvider { platformInterface.reset() } - private func stopService() { + private func stopService() throws { appLogger.info("ExtensionProvider stopService") var error: NSError? MobileStopVPN(&error) - if error != nil { - appLogger.log("error while stopping tunnel \(error?.localizedDescription ?? "")") + if let error { + // A timed-out stop may still be closing the old tunnel. Do not start over it. + appLogger.error("error while stopping tunnel \(error.localizedDescription)") + cancelTunnelWithError(error) + throw error } // Deliberately does not release the claim. This is a teardown primitive: // startTunnel calls it to replace a tunnel it still owns, and restartService @@ -199,7 +193,7 @@ public class ExtensionProvider: NEPacketTunnelProvider { defer { reasserting = false } - stopService() + try stopService() var error: NSError? MobileStartVPN(&error) diff --git a/macos/PacketTunnel/SingBox/TunnelFileDescriptor.swift b/macos/PacketTunnel/SingBox/TunnelFileDescriptor.swift new file mode 100644 index 0000000000..bf4e41ac09 --- /dev/null +++ b/macos/PacketTunnel/SingBox/TunnelFileDescriptor.swift @@ -0,0 +1,108 @@ +import Darwin +import Foundation +import Network + +enum TunnelFileDescriptor { + struct Candidate: Equatable { + let descriptor: Int32 + let interfaceName: String + let addresses: [String] + } + + enum ResolutionError: LocalizedError, Equatable { + case missingAddresses + case noMatchingInterface + case ambiguousInterfaces + + var errorDescription: String? { + switch self { + case .missingAddresses: + return "Cannot identify the tunnel without valid configured addresses" + case .noMatchingInterface: + return "No tunnel interface matches the configured addresses" + case .ambiguousInterfaces: + return "Multiple tunnel interfaces match the configured addresses" + } + } + } + + /// Returns a matching descriptor borrowed from NetworkExtension; it must not be closed here. + static func resolve(addresses: [String]) throws -> Candidate { + let interfaces = try interfaceAddresses() + var candidates: [Candidate] = [] + for descriptor in 0.. Candidate { + let expected = Set(addresses.compactMap(addressBytes)) + guard !expected.isEmpty, addresses.allSatisfy({ addressBytes($0) != nil }) else { + throw ResolutionError.missingAddresses + } + let matches = candidates.filter { + expected.isSubset(of: Set($0.addresses.compactMap(addressBytes))) + } + guard let match = matches.first else { + throw ResolutionError.noMatchingInterface + } + // Reused descriptor numbers cannot distinguish a new tunnel from an orphan. + guard matches.allSatisfy({ $0.interfaceName == match.interfaceName }) else { + throw ResolutionError.ambiguousInterfaces + } + return match + } + + private static func addressBytes(_ address: String) -> Data? { + IPv4Address(address)?.rawValue ?? IPv6Address(address)?.rawValue + } + + private static func interfaceName(_ descriptor: Int32) -> String? { + var name = [CChar](repeating: 0, count: Int(IFNAMSIZ)) + var length = socklen_t(name.count) + guard getsockopt(descriptor, SYSPROTO_CONTROL, UTUN_OPT_IFNAME, &name, &length) == 0 + else { + return nil + } + let value = String(cString: name) + guard value.hasPrefix("utun"), UInt32(value.dropFirst(4)) != nil else { + return nil + } + return value + } + + private static func interfaceAddresses() throws -> [String: [String]] { + var head: UnsafeMutablePointer? + guard getifaddrs(&head) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + defer { freeifaddrs(head) } + var result: [String: [String]] = [:] + var next = head + while let current = next { + next = current.pointee.ifa_next + guard let address = current.pointee.ifa_addr, + address.pointee.sa_family == AF_INET || address.pointee.sa_family == AF_INET6, + current.pointee.ifa_flags & UInt32(IFF_UP) != 0 + else { + continue + } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard + getnameinfo( + address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), + nil, 0, NI_NUMERICHOST) == 0 + else { + continue + } + let name = String(cString: current.pointee.ifa_name) + result[name, default: []].append(String(cString: host)) + } + return result + } +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 8aeea0353b..16d7c917d0 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + A37810012FAD000100000001 /* TunnelFileDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37810022FAD000100000001 /* TunnelFileDescriptor.swift */; }; + A37810032FAD000100000001 /* TunnelFileDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37810022FAD000100000001 /* TunnelFileDescriptor.swift */; }; + A37810042FAD000100000001 /* TunnelFileDescriptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37810052FAD000100000001 /* TunnelFileDescriptorTests.swift */; }; A38250062FAD000100000001 /* AppInstallation.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A38250072FAD000100000001 /* AppInstallation.xcstrings */; }; A38250012FAD000100000001 /* AppInstallation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38250022FAD000100000001 /* AppInstallation.swift */; }; A38250032FAD000100000001 /* AppInstallationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38250042FAD000100000001 /* AppInstallationTests.swift */; }; @@ -141,6 +144,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A37810022FAD000100000001 /* TunnelFileDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelFileDescriptor.swift; sourceTree = ""; }; + A37810052FAD000100000001 /* TunnelFileDescriptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelFileDescriptorTests.swift; sourceTree = ""; }; A38250072FAD000100000001 /* AppInstallation.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = AppInstallation.xcstrings; sourceTree = ""; }; A38250022FAD000100000001 /* AppInstallation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInstallation.swift; sourceTree = ""; }; A38250042FAD000100000001 /* AppInstallationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInstallationTests.swift; sourceTree = ""; }; @@ -270,6 +275,7 @@ 2995B4232DF0AA4700429FE9 /* SingBox */ = { isa = PBXGroup; children = ( + A37810022FAD000100000001 /* TunnelFileDescriptor.swift */, 2995B4202DF0AA4700429FE9 /* Extension+RunBlocking.swift */, 2995B4212DF0AA4700429FE9 /* ExtensionPlatformInterface.swift */, 2995B4222DF0AA4700429FE9 /* ExtensionProvider.swift */, @@ -292,6 +298,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( + A37810052FAD000100000001 /* TunnelFileDescriptorTests.swift */, 331C80D7294CF71000263BE5 /* RunnerTests.swift */, A38250042FAD000100000001 /* AppInstallationTests.swift */, ); @@ -688,6 +695,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A37810012FAD000100000001 /* TunnelFileDescriptor.swift in Sources */, 5F53D0452E4414D000740586 /* FilePath.swift in Sources */, 5F53D01A2E440BBC00740586 /* Extension+RunBlocking.swift in Sources */, 5F53D01B2E440BBC00740586 /* ExtensionPlatformInterface.swift in Sources */, @@ -702,6 +710,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A37810032FAD000100000001 /* TunnelFileDescriptor.swift in Sources */, + A37810042FAD000100000001 /* TunnelFileDescriptorTests.swift in Sources */, 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, A38250032FAD000100000001 /* AppInstallationTests.swift in Sources */, ); diff --git a/macos/RunnerTests/TunnelFileDescriptorTests.swift b/macos/RunnerTests/TunnelFileDescriptorTests.swift new file mode 100644 index 0000000000..88cee9a2e6 --- /dev/null +++ b/macos/RunnerTests/TunnelFileDescriptorTests.swift @@ -0,0 +1,84 @@ +import XCTest + +final class TunnelFileDescriptorTests: XCTestCase { + private typealias Candidate = TunnelFileDescriptor.Candidate + private let address = "10.10.1.1" + + func testSelectsConfiguredInterfaceInsteadOfLowestOrHighestDescriptor() throws { + let live = Candidate(descriptor: 20, interfaceName: "utun4", addresses: [address]) + let candidates = [ + Candidate(descriptor: 10, interfaceName: "utun3", addresses: []), + live, + Candidate(descriptor: 30, interfaceName: "utun5", addresses: ["192.0.2.1"]), + ] + XCTAssertEqual(try TunnelFileDescriptor.select(candidates, addresses: [address]), live) + XCTAssertEqual( + try TunnelFileDescriptor.select(candidates.reversed(), addresses: [address]), live) + } + + func testAcceptsReusedLowerDescriptorForTheLiveInterface() throws { + let live = Candidate(descriptor: 4, interfaceName: "utun5", addresses: [address]) + let orphan = Candidate(descriptor: 20, interfaceName: "utun4", addresses: []) + XCTAssertEqual(try TunnelFileDescriptor.select([orphan, live], addresses: [address]), live) + } + + func testRejectsTwoInterfacesWithTheSameAddress() { + assertError( + .ambiguousInterfaces, + candidates: [ + Candidate(descriptor: 10, interfaceName: "utun3", addresses: [address]), + Candidate(descriptor: 20, interfaceName: "utun4", addresses: [address]), + ]) + } + + func testAllowsDuplicateDescriptorsForTheSameInterface() throws { + let candidates = [ + Candidate(descriptor: 10, interfaceName: "utun4", addresses: [address]), + Candidate(descriptor: 20, interfaceName: "utun4", addresses: [address]), + ] + XCTAssertEqual( + try TunnelFileDescriptor.select(candidates, addresses: [address]).interfaceName, "utun4") + } + + func testRejectsAnOrphanEvenWhenItIsTheOnlyDescriptor() { + assertError( + .noMatchingInterface, + candidates: [Candidate(descriptor: 10, interfaceName: "utun3", addresses: [])]) + assertError(.noMatchingInterface, candidates: []) + } + + func testRequiresEveryConfiguredAddress() { + assertError( + .noMatchingInterface, + candidates: [Candidate(descriptor: 10, interfaceName: "utun3", addresses: [address])], + addresses: [address, "fdfe:dcba:9876::1"]) + } + + func testComparesIPv6AddressesByValue() throws { + let live = Candidate( + descriptor: 20, interfaceName: "utun4", addresses: ["fdfe:dcba:9876::1"]) + XCTAssertEqual( + try TunnelFileDescriptor.select([live], addresses: ["FDFE:DCBA:9876:0:0:0:0:1"]), live) + } + + func testRejectsMissingOrInvalidConfiguredAddresses() { + let candidates = [Candidate(descriptor: 10, interfaceName: "utun3", addresses: [address])] + for addresses in [[], ["invalid"], [address, "invalid"]] { + assertError(.missingAddresses, candidates: candidates, addresses: addresses) + } + } + + private func assertError( + _ expected: TunnelFileDescriptor.ResolutionError, + candidates: [Candidate], addresses: [String]? = nil, + file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertThrowsError( + try TunnelFileDescriptor.select(candidates, addresses: addresses ?? [address]), + file: file, line: line + ) { error in + XCTAssertEqual( + error as? TunnelFileDescriptor.ResolutionError, expected, file: file, line: line) + } + } +} diff --git a/test/integration/vpn_smoke_helpers_test.dart b/test/integration/vpn_smoke_helpers_test.dart new file mode 100644 index 0000000000..e0d4bb07a7 --- /dev/null +++ b/test/integration/vpn_smoke_helpers_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/common/app_dialog.dart'; +import 'package:lantern/core/common/app_eum.dart'; +import 'package:lantern/core/common/app_theme.dart'; + +import '../../integration_test/vpn/vpn_smoke_helpers.dart'; + +void main() { + testWidgets('connect smoke confirms the VPN conflict dialog', (tester) async { + await _showVpnPage(tester, conflict: true); + await tester.tap(find.byKey(const Key('vpn.toggle'))); + + await VpnStateFinders().waitFor( + tester, + expected: const [VPNStatus.connected], + timeout: const Duration(seconds: 2), + reason: 'Connect anyway did not start the VPN', + allowVpnConflict: true, + ); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('connect smoke also works without a VPN conflict', ( + tester, + ) async { + await _showVpnPage(tester, conflict: false); + await tester.tap(find.byKey(const Key('vpn.toggle'))); + + await VpnStateFinders().waitFor( + tester, + expected: const [VPNStatus.connected], + timeout: const Duration(seconds: 2), + reason: 'VPN did not connect', + allowVpnConflict: true, + ); + }); + + testWidgets('waiting for disconnect does not confirm a VPN conflict', ( + tester, + ) async { + await _showVpnPage(tester, conflict: true); + await tester.tap(find.byKey(const Key('vpn.toggle'))); + + await VpnStateFinders().waitFor( + tester, + expected: const [VPNStatus.disconnected], + timeout: const Duration(seconds: 2), + reason: 'VPN should remain disconnected', + ); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + }); +} + +Future _showVpnPage(WidgetTester tester, {required bool conflict}) async { + var state = VPNStatus.disconnected; + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + child: MaterialApp( + theme: AppTheme.appTheme(), + home: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: Column( + children: [ + Text(state.name, key: Key('vpn.status.${state.name}')), + TextButton( + key: const Key('vpn.toggle'), + onPressed: () { + void connect() => + setState(() => state = VPNStatus.connected); + if (!conflict) { + connect(); + return; + } + AppDialog.vpnConflictDialog( + context: context, + onConnectAnyway: () { + Navigator.of(context).pop(); + connect(); + }, + ); + }, + child: const Text('Connect'), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart index 6f82e7ae7e..f217bd8199 100644 --- a/test_driver/integration_test.dart +++ b/test_driver/integration_test.dart @@ -1,5 +1,11 @@ +import 'dart:io'; + import 'package:integration_test/integration_test_driver.dart'; -// The auto-update smoke uses flutter drive so it can control the installed, -// signed profile fixture and leave it running for the native updater handoff. -Future main() => integrationDriver(); +// Drive the installed, signed fixture without rebuilding its native components. +Future main() => integrationDriver( + // Allow the lifecycle test to finish and report before the driver times out. + timeout: Duration( + minutes: Platform.environment['VPN_LIFECYCLE_SMOKE'] == 'true' ? 50 : 20, + ), +);