diff --git a/.github/scripts/windows_payment_checkout_smoke.ps1 b/.github/scripts/windows_payment_checkout_smoke.ps1 new file mode 100644 index 0000000000..6e7108d782 --- /dev/null +++ b/.github/scripts/windows_payment_checkout_smoke.ps1 @@ -0,0 +1,1267 @@ +param( + [ValidateSet("orchestrator", "launcher")] + [string]$Mode = "orchestrator", + [string]$ServiceName = "LanternSvc", + [string]$InstalledAppPath = "C:\Program Files\Lantern\Lantern.exe", + [string]$InstalledDaemonPath = "C:\Program Files\Lantern\lanternd.exe", + [string]$ArtifactDirectory = "build/windows-payment-checkout-smoke", + [int]$WaitSeconds = 180, + [string]$ShepherdHostPattern = '(^|\.)m62mrsf\.com$', + [string]$LauncherConfigPath +) + +$ErrorActionPreference = "Stop" +$resolvedArtifacts = if ($Mode -eq "orchestrator") { + [System.IO.Path]::GetFullPath($ArtifactDirectory) +} else { + $null +} +$serviceDataDirectory = Join-Path $env:ProgramData "Lantern\payment-smoke-data" +$serviceLogDirectory = Join-Path $env:ProgramData "Lantern\payment-smoke-logs" +if ($resolvedArtifacts) { + New-Item -ItemType Directory -Path $resolvedArtifacts -Force | Out-Null +} + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +Add-Type -AssemblyName Accessibility +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +function Initialize-NativeSmokeHelpers { + if ("WindowsPaymentSmoke.NativeToken" -as [type]) { + return + } + + $accessibilityAssembly = [Accessibility.IAccessible].Assembly.Location + Add-Type -ReferencedAssemblies $accessibilityAssembly -TypeDefinition @' +namespace WindowsPaymentSmoke { + public static class NativeMouse { + [System.Runtime.InteropServices.DllImport("user32.dll")] + public static extern void mouse_event( + uint flags, uint dx, uint dy, uint data, System.UIntPtr extraInfo); + } + + public static class NativeToken { + private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + private const uint TOKEN_QUERY = 0x0008; + private const int TokenElevation = 20; + + [System.Runtime.InteropServices.StructLayout( + System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct TOKEN_ELEVATION { + public int TokenIsElevated; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + private static extern System.IntPtr OpenProcess( + uint access, bool inheritHandle, int processId); + + [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenProcessToken( + System.IntPtr processHandle, uint access, out System.IntPtr tokenHandle); + + [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation( + System.IntPtr tokenHandle, + int tokenInformationClass, + out TOKEN_ELEVATION tokenInformation, + int tokenInformationLength, + out int returnLength); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + private static extern bool CloseHandle(System.IntPtr handle); + + public static bool IsElevated(int processId) { + System.IntPtr process = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, false, processId); + if (process == System.IntPtr.Zero) { + throw new System.ComponentModel.Win32Exception(); + } + try { + System.IntPtr token; + if (!OpenProcessToken(process, TOKEN_QUERY, out token)) { + throw new System.ComponentModel.Win32Exception(); + } + try { + TOKEN_ELEVATION elevation; + int returned; + if (!GetTokenInformation( + token, + TokenElevation, + out elevation, + System.Runtime.InteropServices.Marshal.SizeOf(), + out returned)) { + throw new System.ComponentModel.Win32Exception(); + } + return elevation.TokenIsElevated != 0; + } finally { + CloseHandle(token); + } + } finally { + CloseHandle(process); + } + } + } + + public static class NativeAccessibility { + private const uint OBJID_CLIENT = unchecked((uint)-4); + private const int CHILDID_SELF = 0; + private const uint MOUSEEVENTF_LEFTDOWN = 0x0002; + private const uint MOUSEEVENTF_LEFTUP = 0x0004; + + private sealed class Match { + public Accessibility.IAccessible Container; + public object ChildId; + } + + [System.Runtime.InteropServices.DllImport("oleacc.dll")] + private static extern int AccessibleObjectFromWindow( + System.IntPtr window, + uint objectId, + ref System.Guid interfaceId, + out Accessibility.IAccessible accessible); + + [System.Runtime.InteropServices.DllImport("oleacc.dll")] + private static extern int AccessibleChildren( + Accessibility.IAccessible container, + int childStart, + int childCount, + [System.Runtime.InteropServices.In, + System.Runtime.InteropServices.Out, + System.Runtime.InteropServices.MarshalAs( + System.Runtime.InteropServices.UnmanagedType.LPArray, + SizeParamIndex = 2)] + object[] children, + out int obtained); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool SetCursorPos(int x, int y); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern System.IntPtr GetAncestor( + System.IntPtr window, uint flags); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool SetForegroundWindow(System.IntPtr window); + + public static bool ContainsName( + System.IntPtr window, string targetName) { + Accessibility.IAccessible root = Root(window); + if (root == null) { + return false; + } + int visited = 0; + return Find(root, targetName, 0, ref visited) != null; + } + + public static bool ClickByName( + System.IntPtr window, string targetName) { + Accessibility.IAccessible root = Root(window); + if (root == null) { + return false; + } + int visited = 0; + Match match = Find(root, targetName, 0, ref visited); + if (match == null) { + return false; + } + + try { + string action = + match.Container.get_accDefaultAction(match.ChildId); + if (!string.IsNullOrWhiteSpace(action)) { + match.Container.accDoDefaultAction(match.ChildId); + return true; + } + } catch (System.Runtime.InteropServices.COMException) { + } + + int left; + int top; + int width; + int height; + try { + match.Container.accLocation( + out left, out top, out width, out height, match.ChildId); + if (width > 0 && height > 0) { + System.IntPtr topLevel = GetAncestor(window, 2); + if (topLevel != System.IntPtr.Zero) { + SetForegroundWindow(topLevel); + } + if (!SetCursorPos( + left + (width / 2), top + (height / 2))) { + return false; + } + NativeMouse.mouse_event( + MOUSEEVENTF_LEFTDOWN, 0, 0, 0, System.UIntPtr.Zero); + NativeMouse.mouse_event( + MOUSEEVENTF_LEFTUP, 0, 0, 0, System.UIntPtr.Zero); + return true; + } + } catch (System.Runtime.InteropServices.COMException) { + return false; + } + return false; + } + + public static string DescribeNames(System.IntPtr window) { + Accessibility.IAccessible root = Root(window); + if (root == null) { + return "(MSAA root unavailable)"; + } + System.Collections.Generic.List names = + new System.Collections.Generic.List(); + int visited = 0; + CollectNames(root, names, 0, ref visited); + int rootChildren; + try { + rootChildren = root.accChildCount; + } catch (System.Runtime.InteropServices.COMException) { + rootChildren = -1; + } + string description = names.Count == 0 + ? "(no named MSAA elements)" + : string.Join(", ", names.ToArray()); + return string.Format( + "rootChildren={0}; visited={1}; names={2}", + rootChildren, + visited, + description); + } + + private static Accessibility.IAccessible Root(System.IntPtr window) { + System.Guid accessibleId = new System.Guid( + "618736E0-3C3D-11CF-810C-00AA00389B71"); + Accessibility.IAccessible accessible; + int result = AccessibleObjectFromWindow( + window, OBJID_CLIENT, ref accessibleId, out accessible); + return result >= 0 ? accessible : null; + } + + private static Match Find( + Accessibility.IAccessible container, + string targetName, + int depth, + ref int visited) { + if (container == null || depth > 64 || visited >= 5000) { + return null; + } + visited++; + if (NameEquals(container, CHILDID_SELF, targetName)) { + return new Match { + Container = container, + ChildId = CHILDID_SELF, + }; + } + + object[] children = Children(container); + foreach (object child in children) { + Accessibility.IAccessible childAccessible = + child as Accessibility.IAccessible; + if (childAccessible != null) { + Match match = Find( + childAccessible, targetName, depth + 1, ref visited); + if (match != null) { + return match; + } + continue; + } + if (child != null && NameEquals(container, child, targetName)) { + return new Match { + Container = container, + ChildId = child, + }; + } + } + return null; + } + + private static void CollectNames( + Accessibility.IAccessible container, + System.Collections.Generic.List names, + int depth, + ref int visited) { + if (container == null || depth > 64 || + visited >= 5000 || names.Count >= 100) { + return; + } + visited++; + AddName(container, CHILDID_SELF, names); + foreach (object child in Children(container)) { + Accessibility.IAccessible childAccessible = + child as Accessibility.IAccessible; + if (childAccessible != null) { + CollectNames(childAccessible, names, depth + 1, ref visited); + } else if (child != null) { + AddName(container, child, names); + } + } + } + + private static object[] Children( + Accessibility.IAccessible container) { + int childCount; + try { + childCount = container.accChildCount; + } catch (System.Runtime.InteropServices.COMException) { + return new object[0]; + } + if (childCount <= 0) { + return new object[0]; + } + + object[] children = new object[childCount]; + int obtained; + int result = AccessibleChildren( + container, 0, childCount, children, out obtained); + if (result < 0 || obtained <= 0) { + return new object[0]; + } + if (obtained == childCount) { + return children; + } + object[] trimmed = new object[obtained]; + System.Array.Copy(children, trimmed, obtained); + return trimmed; + } + + private static bool NameEquals( + Accessibility.IAccessible container, + object childId, + string targetName) { + string name = Name(container, childId); + return string.Equals( + name, targetName, System.StringComparison.Ordinal); + } + + private static void AddName( + Accessibility.IAccessible container, + object childId, + System.Collections.Generic.List names) { + string name = Name(container, childId); + if (!string.IsNullOrWhiteSpace(name)) { + names.Add(name); + } + } + + private static string Name( + Accessibility.IAccessible container, object childId) { + try { + return container.get_accName(childId); + } catch (System.Runtime.InteropServices.COMException) { + return null; + } + } + } + + public static class NativeWindow { + private delegate bool EnumWindowsProc( + System.IntPtr window, System.IntPtr parameter); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool EnumWindows( + EnumWindowsProc callback, System.IntPtr parameter); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool EnumChildWindows( + System.IntPtr parent, + EnumWindowsProc callback, + System.IntPtr parameter); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId( + System.IntPtr window, out uint processId); + + [System.Runtime.InteropServices.DllImport( + "user32.dll", + CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern int GetClassName( + System.IntPtr window, + System.Text.StringBuilder className, + int maximumCount); + + [System.Runtime.InteropServices.DllImport( + "user32.dll", + CharSet = System.Runtime.InteropServices.CharSet.Unicode, + SetLastError = true)] + public static extern System.IntPtr FindWindowEx( + System.IntPtr parent, + System.IntPtr childAfter, + string className, + string windowName); + + public static System.IntPtr FindByProcessAndClass( + int processId, string targetClass) { + System.IntPtr result = System.IntPtr.Zero; + EnumWindows(delegate(System.IntPtr topLevel, System.IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(topLevel, out owner); + if (owner != processId) { + return true; + } + if (ClassName(topLevel) == targetClass) { + result = topLevel; + return false; + } + EnumChildWindows( + topLevel, + delegate(System.IntPtr child, System.IntPtr childParameter) { + if (ClassName(child) == targetClass) { + result = child; + return false; + } + return true; + }, + System.IntPtr.Zero); + return result == System.IntPtr.Zero; + }, System.IntPtr.Zero); + return result; + } + + public static System.IntPtr FindMessageOnlyByProcessAndClass( + int processId, string targetClass) { + System.IntPtr messageWindow = new System.IntPtr(-3); + System.IntPtr child = System.IntPtr.Zero; + while (true) { + child = FindWindowEx(messageWindow, child, targetClass, null); + if (child == System.IntPtr.Zero) { + return System.IntPtr.Zero; + } + uint owner; + GetWindowThreadProcessId(child, out owner); + if (owner == processId) { + return child; + } + } + } + + public static string DescribeProcessWindows(int processId) { + System.Collections.Generic.List windows = + new System.Collections.Generic.List(); + EnumWindows(delegate(System.IntPtr topLevel, System.IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(topLevel, out owner); + if (owner != processId) { + return true; + } + windows.Add(ClassName(topLevel)); + EnumChildWindows( + topLevel, + delegate(System.IntPtr child, System.IntPtr childParameter) { + windows.Add(ClassName(child)); + return true; + }, + System.IntPtr.Zero); + return true; + }, System.IntPtr.Zero); + return string.Join(",", windows.ToArray()); + } + + private static string ClassName(System.IntPtr window) { + System.Text.StringBuilder className = + new System.Text.StringBuilder(256); + GetClassName(window, className, className.Capacity); + return className.ToString(); + } + } +} +'@ +} + +function Write-Step { + param([string]$Message) + Write-Host ("[{0}] {1}" -f (Get-Date -Format "HH:mm:ss"), $Message) +} + +function Wait-ServiceRunning { + param([string]$Name, [int]$TimeoutSeconds) + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($service -and $service.Status -eq "Running") { + return + } + Start-Sleep -Seconds 1 + } + throw "Service $Name did not reach Running state" +} + +function Use-StagingService { + if (-not (Test-Path $InstalledDaemonPath)) { + throw "Installed daemon not found: $InstalledDaemonPath" + } + Write-Step "Reinstalling the installed service for staging" + Remove-Item $serviceDataDirectory -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $serviceLogDirectory -Recurse -Force -ErrorAction SilentlyContinue + & $InstalledDaemonPath install --environment staging ` + --data-path $serviceDataDirectory ` + --log-path $serviceLogDirectory ` + --log-level debug + if ($LASTEXITCODE -ne 0) { + throw "lanternd staging install failed with exit code $LASTEXITCODE" + } + Wait-ServiceRunning -Name $ServiceName -TimeoutSeconds 60 + Start-Sleep -Seconds 5 + + $service = Get-CimInstance Win32_Service -Filter "Name='$ServiceName'" + if ($service.PathName -notmatch '(?i)--environment\s+staging') { + throw "Installed service command does not persist --environment staging: $($service.PathName)" + } + if ($service.PathName -notmatch [regex]::Escape($serviceDataDirectory)) { + throw "Installed staging service is not using disposable data: $($service.PathName)" + } + $service | Select-Object Name, State, StartMode, PathName | + ConvertTo-Json | Set-Content (Join-Path $resolvedArtifacts "service.json") +} + +function Wait-File { + param([string]$Path, [int]$TimeoutSeconds) + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + if (Test-Path $Path) { return } + Start-Sleep -Seconds 1 + } + throw "Timed out waiting for $Path" +} + +function Wait-ProcessMainWindow { + param([int]$ProcessID, [int]$TimeoutSeconds) + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + $process = Get-Process -Id $ProcessID -ErrorAction SilentlyContinue + if (-not $process) { + throw "Lantern process $ProcessID exited before creating a window" + } + $process.Refresh() + if ($process.MainWindowHandle -ne 0) { + return $process + } + Start-Sleep -Seconds 1 + } + throw "Timed out waiting for Lantern main window" +} + +function Wait-FlutterViewHandle { + param([int]$ProcessID, [int]$TimeoutSeconds) + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + $handle = [WindowsPaymentSmoke.NativeWindow]::FindByProcessAndClass( + $ProcessID, + "FLUTTERVIEW" + ) + if ($handle -ne [IntPtr]::Zero) { + return $handle + } + Start-Sleep -Seconds 1 + } + $messageOnly = [WindowsPaymentSmoke.NativeWindow]::FindMessageOnlyByProcessAndClass( + $ProcessID, + "FLUTTERVIEW" + ) + $windows = [WindowsPaymentSmoke.NativeWindow]::DescribeProcessWindows( + $ProcessID + ) + if ($messageOnly -ne [IntPtr]::Zero) { + throw "Lantern's FLUTTERVIEW remained message-only; windows=$windows" + } + throw "Lantern did not create a FLUTTERVIEW window; windows=$windows" +} + +function Wait-AccessibleElement { + param( + [IntPtr]$ViewHandle, + [string]$AccessibleName, + [int]$TimeoutSeconds, + [string]$FailureLogPath, + [switch]$Optional + ) + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + if ([WindowsPaymentSmoke.NativeAccessibility]::ContainsName( + $ViewHandle, + $AccessibleName + )) { + return $true + } + if ($FailureLogPath -and (Test-Path $FailureLogPath)) { + $failure = Select-String -Path $FailureLogPath ` + -Pattern 'PAYMENT_CHECKOUT_SMOKE event=(rejected|bootstrap_error)' | + Select-Object -Last 1 + if ($failure) { + throw "Lantern checkout bootstrap failed: $($failure.Line.Trim())" + } + } + Start-Sleep -Seconds 1 + } + if ($Optional) { return $false } + $names = [WindowsPaymentSmoke.NativeAccessibility]::DescribeNames( + $ViewHandle + ) + throw "Timed out waiting for accessible element '$AccessibleName'; names=$names" +} + +function Invoke-AccessibleElement { + param( + [IntPtr]$ViewHandle, + [string]$AccessibleName + ) + if (-not [WindowsPaymentSmoke.NativeAccessibility]::ClickByName( + $ViewHandle, + $AccessibleName + )) { + throw "Could not invoke accessible element '$AccessibleName'" + } +} + +function Save-WindowScreenshot { + param( + [System.Windows.Automation.AutomationElement]$Root, + [string]$Path + ) + $bounds = $Root.Current.BoundingRectangle + if ($bounds.Width -le 0 -or $bounds.Height -le 0) { return } + $bitmap = [System.Drawing.Bitmap]::new([int]$bounds.Width, [int]$bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen( + [int]$bounds.X, + [int]$bounds.Y, + 0, + 0, + $bitmap.Size + ) + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +function Save-DesktopScreenshot { + param([string]$Path) + $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen( + $bounds.Left, + $bounds.Top, + 0, + 0, + $bitmap.Size + ) + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +function Copy-CaseLogs { + param([string]$Destination) + $appLogDirectory = Join-Path $env:PUBLIC "Lantern\logs" + if (Test-Path $appLogDirectory) { + Copy-Item $appLogDirectory (Join-Path $Destination "app-logs") -Recurse -Force + } + if (Test-Path $serviceLogDirectory) { + Copy-Item $serviceLogDirectory (Join-Path $Destination "daemon-logs") -Recurse -Force + } +} + +function Wait-CheckoutDocument { + param( + [string]$LogPath, + [string]$HostPattern, + [int]$TimeoutSeconds, + [string]$ResultPath + ) + $linePattern = 'PAYMENT_WEBVIEW_SMOKE event=load_stop host=(\S+) url=(\S+) document_length=(\d+)' + $lastLogText = "" + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + if (Test-Path $LogPath) { + $lastLogText = [string]( + Get-Content $LogPath -Raw -ErrorAction SilentlyContinue + ) + if ($lastLogText -match 'PAYMENT_CHECKOUT_SMOKE event=(rejected|bootstrap_error)') { + throw "Lantern could not prepare the requested checkout smoke" + } + foreach ($match in [regex]::Matches($lastLogText, $linePattern)) { + $hostName = $match.Groups[1].Value + $documentLength = [int]$match.Groups[3].Value + if ($hostName -match $HostPattern -and $documentLength -gt 0) { + if ($lastLogText -notmatch 'PAYMENT_WEBVIEW_SMOKE event=created') { + throw "The checkout page loaded without a WebView creation marker" + } + @{ + host = $hostName + url = $match.Groups[2].Value + documentLength = $documentLength + } | ConvertTo-Json | Set-Content $ResultPath + return + } + } + } + Start-Sleep -Seconds 1 + } + if ($lastLogText -match 'PAYMENT_WEBVIEW_SMOKE event=navigation_error') { + throw "The checkout WebView reported a main-frame navigation error" + } + if ($lastLogText -match 'PAYMENT_WEBVIEW_SMOKE event=document_error') { + throw "The checkout WebView could not inspect the loaded document" + } + throw "No non-empty checkout document loaded from expected host pattern $HostPattern" +} + +function Wait-LauncherResult { + param( + [string]$Path, + [System.Diagnostics.Process]$Process, + [int]$TimeoutSeconds, + [string]$ErrorPath + ) + for ($i = 0; $i -lt $TimeoutSeconds; $i++) { + if (Test-Path $Path) { return } + $Process.Refresh() + if ($Process.HasExited) { + Start-Sleep -Seconds 1 + if (Test-Path $Path) { return } + $errorDetails = if ($ErrorPath -and (Test-Path $ErrorPath)) { + (Get-Content $ErrorPath -Raw -ErrorAction SilentlyContinue).Trim() + } else { + "" + } + if ($errorDetails) { + throw "Smoke-user launcher exited with code $($Process.ExitCode): $errorDetails" + } + throw "Smoke-user launcher exited with code $($Process.ExitCode) without writing a result" + } + Start-Sleep -Seconds 1 + } + throw "Timed out waiting for the smoke-user launcher" +} + +function Invoke-SmokeUserCheckout { + param( + [string]$AppPath, + [string]$CheckoutProvider, + [string]$CheckoutRunID, + [string]$ProcessDiagnosticsPath, + [string]$WebViewUDFPath, + [string]$UserProfilePath, + [string]$ResultPath, + [string]$AppLogPath, + [string]$ExpectedHostPattern, + [string]$WebViewPath, + [string]$CheckoutImagePath, + [string]$FinalImagePath, + [string]$TranscriptPath, + [int]$TimeoutSeconds + ) + + $app = $null + $root = $null + $transcriptStarted = $false + try { + $env:USERPROFILE = $UserProfilePath + $env:LOCALAPPDATA = Join-Path $UserProfilePath "AppData\Local" + $env:APPDATA = Join-Path $UserProfilePath "AppData\Roaming" + $env:HOMEDRIVE = Split-Path -Qualifier $UserProfilePath + $env:HOMEPATH = $UserProfilePath.Substring($env:HOMEDRIVE.Length) + $env:TEMP = Join-Path $env:LOCALAPPDATA "Temp" + $env:TMP = $env:TEMP + New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null + Start-Transcript -Path $TranscriptPath -Force | Out-Null + $transcriptStarted = $true + $null = Initialize-NativeSmokeHelpers + [Environment]::SetEnvironmentVariable( + "WEBVIEW2_USER_DATA_FOLDER", + $null, + [EnvironmentVariableTarget]::Process + ) + + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $isAdmin = $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator + ) + $installWritePath = Join-Path (Split-Path $AppPath -Parent) ( + "smoke-write-" + [Guid]::NewGuid().ToString("N") + ) + $canWriteInstallDirectory = $false + try { + New-Item -ItemType File -Path $installWritePath -Force ` + -ErrorAction Stop | Out-Null + $canWriteInstallDirectory = $true + } catch { + } finally { + Remove-Item $installWritePath -Force -ErrorAction SilentlyContinue + } + + $localAppData = $env:LOCALAPPDATA + $externalUDF = [Environment]::GetEnvironmentVariable( + "WEBVIEW2_USER_DATA_FOLDER", + "Process" + ) + $app = Start-Process -FilePath $AppPath -ArgumentList @( + "--payment-checkout-smoke=$CheckoutProvider", + "--payment-checkout-run-id=$CheckoutRunID" + ) -PassThru + @{ + processId = $app.Id + username = $identity.Name + isAdmin = $isAdmin + canWriteInstallDirectory = $canWriteInstallDirectory + externalWebView2UserDataFolder = $externalUDF + localAppData = $localAppData + profilePath = $UserProfilePath + } | ConvertTo-Json | Set-Content $ProcessDiagnosticsPath + + $app = Wait-ProcessMainWindow -ProcessID $app.Id -TimeoutSeconds 90 + $flutterViewHandle = Wait-FlutterViewHandle ` + -ProcessID $app.Id -TimeoutSeconds 30 + $processIsElevated = [WindowsPaymentSmoke.NativeToken]::IsElevated($app.Id) + $diagnostics = Get-Content $ProcessDiagnosticsPath -Raw | ConvertFrom-Json + $diagnostics | Add-Member -NotePropertyName processIsElevated ` + -NotePropertyValue $processIsElevated + $diagnostics | Add-Member -NotePropertyName mainWindowHandle ` + -NotePropertyValue ([IntPtr]$app.MainWindowHandle).ToInt64() + $diagnostics | Add-Member -NotePropertyName flutterViewHandle ` + -NotePropertyValue $flutterViewHandle.ToInt64() + $diagnostics | ConvertTo-Json | Set-Content $ProcessDiagnosticsPath + if ($isAdmin -or $processIsElevated) { + throw "The installed Lantern process has an elevated access token" + } + if ($canWriteInstallDirectory) { + throw "The smoke user can write beside the installed executable" + } + if (-not [string]::IsNullOrWhiteSpace($externalUDF)) { + throw "Primary smoke inherited WEBVIEW2_USER_DATA_FOLDER=$externalUDF" + } + + Write-Step "Attached accessibility automation to Lantern's FLUTTERVIEW child window" + $root = [System.Windows.Automation.AutomationElement]::FromHandle( + $flutterViewHandle + ) + $providerTitle = (Get-Culture).TextInfo.ToTitleCase($CheckoutProvider) + $providerName = "$providerTitle payment method" + $checkoutName = "Continue with $providerTitle" + $null = Wait-AccessibleElement -ViewHandle $flutterViewHandle ` + -AccessibleName $providerName -TimeoutSeconds 90 ` + -FailureLogPath $AppLogPath + $checkoutVisible = Wait-AccessibleElement -ViewHandle $flutterViewHandle ` + -AccessibleName $checkoutName ` + -TimeoutSeconds 2 -Optional + if (-not $checkoutVisible) { + Invoke-AccessibleElement -ViewHandle $flutterViewHandle ` + -AccessibleName $providerName + $null = Wait-AccessibleElement -ViewHandle $flutterViewHandle ` + -AccessibleName $checkoutName ` + -TimeoutSeconds 20 -FailureLogPath $AppLogPath + } + Invoke-AccessibleElement -ViewHandle $flutterViewHandle ` + -AccessibleName $checkoutName + + Wait-CheckoutDocument -LogPath $AppLogPath ` + -HostPattern $ExpectedHostPattern -TimeoutSeconds $TimeoutSeconds ` + -ResultPath $WebViewPath + + $udfPath = Join-Path $localAppData "Lantern\WebView2" + for ($i = 0; $i -lt 30; $i++) { + if (Test-Path $udfPath) { break } + if ($app.HasExited) { break } + Start-Sleep -Seconds 1 + $app.Refresh() + } + $udfWritable = $false + $udfProbe = Join-Path $udfPath ( + "write-probe-" + [Guid]::NewGuid().ToString("N") + ) + try { + New-Item -ItemType File -Path $udfProbe -Force ` + -ErrorAction Stop | Out-Null + $udfWritable = $true + } catch { + } finally { + Remove-Item $udfProbe -Force -ErrorAction SilentlyContinue + } + @{ + path = $udfPath + exists = (Test-Path $udfPath) + writable = $udfWritable + } | ConvertTo-Json | Set-Content $WebViewUDFPath + + Save-WindowScreenshot -Root $root -Path $CheckoutImagePath + Save-WindowScreenshot -Root $root -Path $FinalImagePath + @{ + success = $true + processId = $app.Id + provider = $CheckoutProvider + } | ConvertTo-Json | Set-Content $ResultPath + + $app.WaitForExit() + } catch { + $failure = $_ + try { + if ($root) { + Save-WindowScreenshot -Root $root -Path $FinalImagePath + } else { + Save-DesktopScreenshot -Path $FinalImagePath + } + } catch { + Write-Warning "Could not capture smoke-user screenshot: $_" + } + @{ + success = $false + error = $failure.Exception.Message + provider = $CheckoutProvider + } | ConvertTo-Json | Set-Content $ResultPath + throw $failure + } finally { + if ($transcriptStarted) { + try { + Stop-Transcript | Out-Null + } catch { + } + } + } +} + +function Remove-SmokeAccount { + param([string]$Username, [string]$SID) + if (Get-LocalUser -Name $Username -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $Username + } + if ([string]::IsNullOrWhiteSpace($SID)) { return } + for ($i = 0; $i -lt 15; $i++) { + $profile = Get-CimInstance Win32_UserProfile -Filter "SID='$SID'" -ErrorAction SilentlyContinue + if (-not $profile) { return } + if (-not $profile.Loaded) { + Remove-CimInstance $profile + return + } + Start-Sleep -Seconds 1 + } + Write-Warning "Disposable profile $SID remained loaded and could not be removed" +} + +function Invoke-CheckoutCase { + param([string]$Provider, [string]$HostPattern, [string]$RunID) + + $caseDirectory = Join-Path $resolvedArtifacts $Provider + New-Item -ItemType Directory -Path $caseDirectory -Force | Out-Null + @{ + provider = $Provider + runId = $RunID + githubRunId = $env:GITHUB_RUN_ID + githubRunAttempt = $env:GITHUB_RUN_ATTEMPT + } | ConvertTo-Json | Set-Content (Join-Path $caseDirectory "run.json") + $transcript = Join-Path $caseDirectory "orchestration.log" + $username = "lntsmk" + ([Guid]::NewGuid().ToString("N").Substring(0, 10)) + $sid = $null + $credential = $null + $launcherProcess = $null + $appProcess = $null + $sharedAppDirectory = $null + $diagnosticsPath = $null + $udfCheckPath = $null + $automationResultPath = $null + $childWebViewPath = $null + $childCheckoutImagePath = $null + $childFinalImagePath = $null + $launcherLogPath = $null + $launcherStdoutPath = $null + $launcherStderrPath = $null + $launcherConfigPath = $null + $profilePath = $null + $transcriptStarted = $false + $priorUDF = [Environment]::GetEnvironmentVariable( + "WEBVIEW2_USER_DATA_FOLDER", + [EnvironmentVariableTarget]::Process + ) + + try { + Start-Transcript -Path $transcript -Force | Out-Null + $transcriptStarted = $true + $passwordText = "L@ntern!" + ([Guid]::NewGuid().ToString("N")) + $securePassword = ConvertTo-SecureString $passwordText -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new( + ".\$username", + $securePassword + ) + $user = New-LocalUser -Name $username -Password $securePassword ` + -AccountNeverExpires -PasswordNeverExpires + $sid = $user.SID.Value + # The daemon accepts members of Administrators. UAC still gives the app a + # filtered, non-elevated token, which the checks below verify. + $administrators = Get-LocalGroup -SID "S-1-5-32-544" + Add-LocalGroupMember -Group $administrators -Member $user + $administratorsMember = Get-LocalGroupMember -Group $administrators | + Where-Object { $_.SID.Value -eq $sid } + if (-not $administratorsMember) { + throw "Could not grant the smoke user access to the installed Lantern service" + } + + # Create the profile before the real launch so the child process does not + # inherit the runner account's AppData paths. + $profileBootstrap = Start-Process -FilePath "powershell.exe" ` + -Credential $credential -LoadUserProfile -Wait -PassThru ` + -ArgumentList @("-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "exit 0") + if ($profileBootstrap.ExitCode -ne 0) { + throw "Could not initialize the disposable user profile" + } + $profile = $null + for ($i = 0; $i -lt 15; $i++) { + $profile = Get-CimInstance Win32_UserProfile -Filter "SID='$sid'" ` + -ErrorAction SilentlyContinue + if ($profile -and -not [string]::IsNullOrWhiteSpace($profile.LocalPath)) { + break + } + Start-Sleep -Seconds 1 + } + if (-not $profile -or [string]::IsNullOrWhiteSpace($profile.LocalPath)) { + throw "Windows did not create a profile for disposable user $username" + } + $profilePath = [Environment]::ExpandEnvironmentVariables($profile.LocalPath) + + Write-Step "Starting $Provider checkout as disposable non-elevated user $username" + [Environment]::SetEnvironmentVariable( + "WEBVIEW2_USER_DATA_FOLDER", + $null, + [EnvironmentVariableTarget]::Process + ) + + $sharedAppDirectory = Join-Path $env:PUBLIC "Lantern" + New-Item -ItemType Directory -Path $sharedAppDirectory -Force | Out-Null + & icacls.exe $sharedAppDirectory /grant "*$sid`:(OI)(CI)M" /T /C | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Could not grant the smoke user access to $sharedAppDirectory" + } + + $exchangeDirectory = Join-Path $sharedAppDirectory "payment-smoke\$RunID-$Provider" + New-Item -ItemType Directory -Path $exchangeDirectory -Force | Out-Null + Remove-Item (Join-Path $sharedAppDirectory "data") -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $sharedAppDirectory "logs") -Recurse -Force -ErrorAction SilentlyContinue + + $diagnosticsPath = Join-Path $exchangeDirectory "process.json" + $udfCheckPath = Join-Path $exchangeDirectory "udf.json" + $automationResultPath = Join-Path $exchangeDirectory "automation-result.json" + $childWebViewPath = Join-Path $exchangeDirectory "webview.json" + $childCheckoutImagePath = Join-Path $exchangeDirectory "checkout.png" + $childFinalImagePath = Join-Path $exchangeDirectory "final.png" + $launcherLogPath = Join-Path $exchangeDirectory "automation.log" + $launcherStdoutPath = Join-Path $exchangeDirectory "launcher-stdout.log" + $launcherStderrPath = Join-Path $exchangeDirectory "launcher-stderr.log" + $launcherPath = Join-Path $exchangeDirectory "launch.ps1" + $launcherConfigPath = Join-Path $exchangeDirectory "launcher.json" + $flutterLog = Join-Path $env:PUBLIC "Lantern\logs\flutter.log" + # Run UI Automation in the same logon session as Flutter. Cross-user + # automation can see the native host window but not Flutter's semantics. + Copy-Item $PSCommandPath $launcherPath -Force + @{ + appPath = $InstalledAppPath + provider = $Provider + runId = $RunID + diagnosticsPath = $diagnosticsPath + udfCheckPath = $udfCheckPath + profilePath = $profilePath + automationResultPath = $automationResultPath + flutterLogPath = $flutterLog + hostPattern = $HostPattern + webViewResultPath = $childWebViewPath + checkoutScreenshotPath = $childCheckoutImagePath + finalScreenshotPath = $childFinalImagePath + launcherLogPath = $launcherLogPath + waitSeconds = $WaitSeconds + } | ConvertTo-Json | Set-Content $launcherConfigPath + + $launcherArguments = @( + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", "`"$launcherPath`"", + "-Mode", "launcher", + "-LauncherConfigPath", "`"$launcherConfigPath`"" + ) + $launcherCommandLine = $launcherArguments -join " " + if ($launcherCommandLine.Length -gt 900) { + throw "Smoke-user launcher command line is too long" + } + $launcherProcess = Start-Process -FilePath "powershell.exe" ` + -Credential $credential -LoadUserProfile ` + -ArgumentList $launcherArguments ` + -RedirectStandardOutput $launcherStdoutPath ` + -RedirectStandardError $launcherStderrPath -PassThru + + Wait-LauncherResult -Path $automationResultPath -Process $launcherProcess ` + -TimeoutSeconds ($WaitSeconds + 150) -ErrorPath $launcherStderrPath + $automationResult = Get-Content $automationResultPath -Raw | + ConvertFrom-Json + if (-not $automationResult.success) { + throw "Smoke-user checkout failed: $($automationResult.error)" + } + + $diagnostics = Get-Content $diagnosticsPath -Raw | ConvertFrom-Json + if ($diagnostics.isAdmin) { + throw "The installed app was launched with an administrator token" + } + if ($diagnostics.canWriteInstallDirectory) { + throw "The smoke user can write beside the installed executable" + } + if (-not [string]::IsNullOrWhiteSpace($diagnostics.externalWebView2UserDataFolder)) { + throw "Primary smoke inherited WEBVIEW2_USER_DATA_FOLDER=$($diagnostics.externalWebView2UserDataFolder)" + } + $expectedLocalAppData = Join-Path $profilePath "AppData\Local" + if ($diagnostics.localAppData -ne $expectedLocalAppData) { + throw "Lantern inherited the wrong LOCALAPPDATA: $($diagnostics.localAppData)" + } + $diagnostics | ConvertTo-Json | Set-Content (Join-Path $caseDirectory "process.json") + if ($diagnostics.processIsElevated) { + throw "The installed Lantern process has an elevated access token" + } + $appProcess = Get-Process -Id $diagnostics.processId -ErrorAction Stop + + Wait-File -Path $udfCheckPath -TimeoutSeconds 30 + $udf = Get-Content $udfCheckPath -Raw | ConvertFrom-Json + Copy-Item $udfCheckPath (Join-Path $caseDirectory "webview2-udf.json") -Force + $expectedUDF = Join-Path $diagnostics.localAppData "Lantern\WebView2" + if (-not $udf.exists -or -not $udf.writable -or $udf.path -ne $expectedUDF) { + throw "Unexpected or unwritable WebView2 folder: $($udf | ConvertTo-Json -Compress)" + } + $escapedExpectedUDF = [regex]::Escape($expectedUDF) + $flutterLogText = Get-Content $flutterLog -Raw + if ($flutterLogText -notmatch "WEBVIEW2_DIAGNOSTIC user_data_folder=$escapedExpectedUDF") { + throw "Flutter did not record the actual per-user WebView2 folder $expectedUDF" + } + + Write-Step "$Provider checkout rendered successfully" + } finally { + if ($appProcess) { + Stop-Process -Id $appProcess.Id -Force -ErrorAction SilentlyContinue + } elseif ($diagnosticsPath -and (Test-Path $diagnosticsPath)) { + try { + $startedProcessID = (Get-Content $diagnosticsPath -Raw | ConvertFrom-Json).processId + Stop-Process -Id $startedProcessID -Force -ErrorAction SilentlyContinue + } catch { + } + } + if ($launcherProcess) { + $launcherProcess.Refresh() + if (-not $launcherProcess.HasExited -and + -not $launcherProcess.WaitForExit(5000)) { + Stop-Process -Id $launcherProcess.Id -Force -ErrorAction SilentlyContinue + } + } + Start-Sleep -Seconds 1 + $childArtifacts = @( + [pscustomobject]@{ + Source = $launcherLogPath + Destination = Join-Path $caseDirectory "automation.log" + }, + [pscustomobject]@{ + Source = $launcherStdoutPath + Destination = Join-Path $caseDirectory "launcher-stdout.log" + }, + [pscustomobject]@{ + Source = $launcherStderrPath + Destination = Join-Path $caseDirectory "launcher-stderr.log" + }, + [pscustomobject]@{ + Source = $childWebViewPath + Destination = Join-Path $caseDirectory "webview.json" + }, + [pscustomobject]@{ + Source = $childCheckoutImagePath + Destination = Join-Path $caseDirectory "checkout.png" + }, + [pscustomobject]@{ + Source = $childFinalImagePath + Destination = Join-Path $caseDirectory "final.png" + } + ) + foreach ($artifact in $childArtifacts) { + if ($artifact.Source -and (Test-Path $artifact.Source)) { + try { + Copy-Item $artifact.Source $artifact.Destination -Force + } catch { + Write-Warning "Could not collect $($artifact.Source): $_" + } + } + } + try { + if (-not (Test-Path (Join-Path $caseDirectory "final.png"))) { + Save-DesktopScreenshot -Path (Join-Path $caseDirectory "final.png") + } + } catch { + Write-Warning "Could not capture final screenshot: $_" + } + if ($udfCheckPath -and (Test-Path $udfCheckPath)) { + Copy-Item $udfCheckPath (Join-Path $caseDirectory "webview2-udf.json") -Force + } + if ($diagnosticsPath -and (Test-Path $diagnosticsPath) -and + -not (Test-Path (Join-Path $caseDirectory "process.json"))) { + Copy-Item $diagnosticsPath (Join-Path $caseDirectory "process.json") -Force + } + $finalFlutterLog = Join-Path $env:PUBLIC "Lantern\logs\flutter.log" + if (Test-Path $finalFlutterLog) { + Select-String -Path $finalFlutterLog ` + -Pattern 'PAYMENT_WEBVIEW_SMOKE|WEBVIEW2_DIAGNOSTIC|PAYMENT_CHECKOUT_SMOKE' | + ForEach-Object { $_.Line } | + Set-Content (Join-Path $caseDirectory "webview-events.log") + } + try { + Copy-CaseLogs -Destination $caseDirectory + } catch { + Write-Warning "Could not collect payment smoke logs: $_" + } + if ($sid -and $sharedAppDirectory -and (Test-Path $sharedAppDirectory)) { + & icacls.exe $sharedAppDirectory /remove "*$sid" /T /C | Out-Null + } + [Environment]::SetEnvironmentVariable( + "WEBVIEW2_USER_DATA_FOLDER", + $priorUDF, + [EnvironmentVariableTarget]::Process + ) + if ($sid -or (Get-LocalUser -Name $username -ErrorAction SilentlyContinue)) { + try { + Remove-SmokeAccount -Username $username -SID $sid + } catch { + Write-Warning "Could not fully remove disposable smoke account: $_" + } + } + if ($transcriptStarted) { + try { + Stop-Transcript | Out-Null + } catch { + } + } + } +} + +if ($Mode -eq "launcher") { + if (-not $LauncherConfigPath -or -not (Test-Path $LauncherConfigPath)) { + throw "Smoke-user launcher config not found: $LauncherConfigPath" + } + $launcherConfig = Get-Content $LauncherConfigPath -Raw | ConvertFrom-Json + Invoke-SmokeUserCheckout ` + -AppPath $launcherConfig.appPath ` + -CheckoutProvider $launcherConfig.provider ` + -CheckoutRunID $launcherConfig.runId ` + -ProcessDiagnosticsPath $launcherConfig.diagnosticsPath ` + -WebViewUDFPath $launcherConfig.udfCheckPath ` + -UserProfilePath $launcherConfig.profilePath ` + -ResultPath $launcherConfig.automationResultPath ` + -AppLogPath $launcherConfig.flutterLogPath ` + -ExpectedHostPattern $launcherConfig.hostPattern ` + -WebViewPath $launcherConfig.webViewResultPath ` + -CheckoutImagePath $launcherConfig.checkoutScreenshotPath ` + -FinalImagePath $launcherConfig.finalScreenshotPath ` + -TranscriptPath $launcherConfig.launcherLogPath ` + -TimeoutSeconds $launcherConfig.waitSeconds + return +} + +if (-not (Test-Path $InstalledAppPath)) { + throw "Installed Lantern executable not found: $InstalledAppPath" +} + +Use-StagingService +Invoke-CheckoutCase -Provider "stripe" -HostPattern '^checkout\.stripe\.com$' ` + -RunID ([Guid]::NewGuid().ToString()) +Invoke-CheckoutCase -Provider "shepherd" -HostPattern $ShepherdHostPattern ` + -RunID ([Guid]::NewGuid().ToString()) diff --git a/.github/scripts/windows_smoke_suite.ps1 b/.github/scripts/windows_smoke_suite.ps1 index d16e0508bd..752fc366bf 100644 --- a/.github/scripts/windows_smoke_suite.ps1 +++ b/.github/scripts/windows_smoke_suite.ps1 @@ -16,6 +16,8 @@ param( [switch]$ForceFullTunnel, [switch]$RunSplitTunnelWebsiteSmoke, [switch]$RunConfigUrlSmoke, + [switch]$RunPaymentCheckoutSmoke, + [string]$PaymentSmokeArtifactDirectory = "build/windows-payment-checkout-smoke", [switch]$UseInstaller ) @@ -365,6 +367,21 @@ try { -ForceFullTunnel:$ForceFullTunnel } } + + if ($RunPaymentCheckoutSmoke) { + if (-not $UseInstaller) { + throw "The payment checkout smoke requires the generated installer" + } + Write-Step "Running installed Windows payment checkout smoke tests" + & "$PSScriptRoot/windows_payment_checkout_smoke.ps1" ` + -ServiceName $ServiceName ` + -ArtifactDirectory $PaymentSmokeArtifactDirectory + if ($LASTEXITCODE -ne 0) { + throw "Windows payment checkout smoke failed with exit code $LASTEXITCODE" + } + } else { + Write-Step "Skipping installed Windows payment checkout smoke tests." + } } finally { try { diff --git a/.github/workflows/app-smoke-tests.yml b/.github/workflows/app-smoke-tests.yml index 67b341d86d..26c8d18f8c 100644 --- a/.github/workflows/app-smoke-tests.yml +++ b/.github/workflows/app-smoke-tests.yml @@ -27,6 +27,11 @@ on: required: false type: boolean default: true + windows_payment_checkout_smoke: + description: "Render staging Stripe and Shepherd checkout in the installed Windows app" + required: false + type: boolean + default: true macos_connect_smoke: description: "Include macOS connect/disconnect smoke when platforms=all (requires self-hosted runner with approved system extension)" required: false @@ -145,6 +150,7 @@ jobs: skip_signing: true enable_ip_check: ${{ inputs.enable_ip_check }} run_connect_smoke: ${{ inputs.windows_connect_smoke }} + run_payment_checkout_smoke: ${{ inputs.windows_payment_checkout_smoke }} run_split_tunnel_website_smoke: ${{ inputs.windows_split_tunnel_website_smoke }} run_config_url_smoke: false force_full_tunnel_smoke: ${{ inputs.force_full_tunnel_smoke }} diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index d2f5be017a..4af7af5031 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_checkout_smoke: + description: "Run installed-app staging payment checkout smoke tests" + required: false + type: boolean + default: false run_split_tunnel_website_smoke: description: "Run website split tunneling smoke test" required: false @@ -302,16 +307,27 @@ jobs: $forceFullTunnelSmoke = "${{ inputs.force_full_tunnel_smoke }}" -eq "true" $enableIpCheck = "${{ inputs.enable_ip_check }}" -eq "true" $runConnectSmoke = "${{ inputs.run_connect_smoke }}" -eq "true" - Write-Host "Windows smoke options: connect=$runConnectSmoke splitTunnelWebsite=$runSplitTunnelWebsiteSmoke configUrl=$runConfigUrlSmoke forceFullTunnel=$forceFullTunnelSmoke ipCheck=$enableIpCheck" + $runPaymentCheckoutSmoke = "${{ inputs.run_payment_checkout_smoke }}" -eq "true" + Write-Host "Windows smoke options: connect=$runConnectSmoke paymentCheckout=$runPaymentCheckoutSmoke splitTunnelWebsite=$runSplitTunnelWebsiteSmoke configUrl=$runConfigUrlSmoke forceFullTunnel=$forceFullTunnelSmoke ipCheck=$enableIpCheck" ./.github/scripts/windows_smoke_suite.ps1 ` -UseInstaller ` -InstallerPath $installerPath ` -RunConnectSmoke:$runConnectSmoke ` + -RunPaymentCheckoutSmoke:$runPaymentCheckoutSmoke ` -RunSplitTunnelWebsiteSmoke:$runSplitTunnelWebsiteSmoke ` -RunConfigUrlSmoke:$runConfigUrlSmoke ` -EnableIpCheck:$enableIpCheck ` -ForceFullTunnel:$forceFullTunnelSmoke + - name: Upload Windows payment checkout smoke artifacts + if: ${{ always() && inputs.run_payment_checkout_smoke }} + uses: actions/upload-artifact@v4 + with: + name: windows-payment-checkout-smoke + path: build/windows-payment-checkout-smoke + if-no-files-found: warn + retention-days: 7 + # Transitional fallback kept in place while the auth + connect smoke suite stabilizes. - name: Windows UI auth smoke integration if: ${{ inputs.run_auth_smoke }} diff --git a/go.mod b/go.mod index 23c1bcfdc5..c170b2dff6 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260730161730-76fcac0ceebb + github.com/getlantern/radiance v0.0.0-20260724161730-2ef18c71ad81 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.45.0 diff --git a/go.sum b/go.sum index 2b709c573a..d6491c20d7 100644 --- a/go.sum +++ b/go.sum @@ -259,8 +259,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20260730161730-76fcac0ceebb h1:1VhnolDkNBFI7LmG6IAHgabJKrR7/B8LOYLFEne5wuo= -github.com/getlantern/radiance v0.0.0-20260730161730-76fcac0ceebb/go.mod h1:BUKzQxV+sKuRySBLPTVHDt1XkPqoIdx/G+pB9euO4LQ= +github.com/getlantern/radiance v0.0.0-20260724161730-2ef18c71ad81 h1:goJj8l9quR8bi4A4UGewR0l+euUSUAuEnFEKmLMYtE4= +github.com/getlantern/radiance v0.0.0-20260724161730-2ef18c71ad81/go.mod h1:3ioylshJuvd1dJEUydJvIWjSXllphapzDk8HQgiyPnc= github.com/getlantern/samizdat v0.0.3-0.20260724223841-a5ee9ab56830 h1:RZd3CELOtQUEzIE5kPdEO9HYg+7JYbR3OTdC+3P/wng= github.com/getlantern/samizdat v0.0.3-0.20260724223841-a5ee9ab56830/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= diff --git a/ios/Tunnel/SingBox/ExtensionProvider.swift b/ios/Tunnel/SingBox/ExtensionProvider.swift index 88d6f1bda1..4be6cdb22d 100644 --- a/ios/Tunnel/SingBox/ExtensionProvider.swift +++ b/ios/Tunnel/SingBox/ExtensionProvider.swift @@ -110,7 +110,7 @@ class ExtensionProvider: NEPacketTunnelProvider { opts.dataDir = FilePath.dataDirectory.relativePath opts.logDir = FilePath.logsDirectory.relativePath // Intentionally left empty. The app and extension don't share a keychain access - // Radiance resolves the device ID from the main app process. + // Radiance resolves the device ID from the main app process. opts.deviceid = "" opts.logLevel = "trace" opts.locale = Locale.current.identifier diff --git a/lib/core/smoke/payment_checkout_smoke.dart b/lib/core/smoke/payment_checkout_smoke.dart new file mode 100644 index 0000000000..f2b14a20cf --- /dev/null +++ b/lib/core/smoke/payment_checkout_smoke.dart @@ -0,0 +1,67 @@ +/// Command-line settings for the Windows checkout smoke. +class PaymentCheckoutSmokeConfig { + static const _providerPrefix = '--payment-checkout-smoke='; + static const _runIDPrefix = '--payment-checkout-run-id='; + static final _runIDPattern = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-' + r'[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$', + ); + + final String provider; + final String runID; + + const PaymentCheckoutSmokeConfig({ + required this.provider, + required this.runID, + }); + + String get email => 'e2e+$runID@getlantern.org'; + + static PaymentCheckoutSmokeConfig? parse( + List arguments, { + required bool isWindows, + required String buildType, + }) { + final providerArgument = _singleArgument(arguments, _providerPrefix); + final runIDArgument = _singleArgument(arguments, _runIDPrefix); + + if (providerArgument == null && runIDArgument == null) { + return null; + } + if (!isWindows || buildType != 'nightly') { + throw const FormatException( + 'Payment checkout smoke mode is available only in Windows nightly builds', + ); + } + if (providerArgument == null || runIDArgument == null) { + throw const FormatException( + 'Payment checkout smoke mode requires a provider and run ID', + ); + } + + final provider = providerArgument.toLowerCase(); + if (provider != 'stripe' && provider != 'shepherd') { + throw FormatException('Unsupported payment checkout provider: $provider'); + } + if (!_runIDPattern.hasMatch(runIDArgument)) { + throw const FormatException('Invalid payment checkout smoke run ID'); + } + + return PaymentCheckoutSmokeConfig(provider: provider, runID: runIDArgument); + } + + static String? _singleArgument(List arguments, String prefix) { + final matches = arguments.where((argument) => argument.startsWith(prefix)); + if (matches.length > 1) { + throw FormatException('Argument may be specified only once: $prefix'); + } + if (matches.isEmpty) { + return null; + } + final value = matches.single.substring(prefix.length).trim(); + if (value.isEmpty) { + throw FormatException('Argument requires a value: $prefix'); + } + return value; + } +} diff --git a/lib/core/widgets/app_webview.dart b/lib/core/widgets/app_webview.dart index 16aeee0018..21b557a660 100644 --- a/lib/core/widgets/app_webview.dart +++ b/lib/core/widgets/app_webview.dart @@ -100,7 +100,10 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { shouldOverrideUrlLoading: shouldOverrideUrlLoading, initialUrlRequest: _initialRequest, initialSettings: setting, - onWebViewCreated: (controller) {}, + onWebViewCreated: (_) { + final uri = Uri.tryParse(widget.url); + _logSmokeEvent('created', uri); + }, onCreateWindow: (controller, createWindowAction) async { final req = createWindowAction.request; if (PlatformUtils.isWindows) { @@ -120,29 +123,92 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { return false; }, onLoadStart: (_, webUri) async { - // Handle load start final loading = ref.read(webViewLoadingProvider.notifier); loading.start(); + _logSmokeEvent( + 'load_start', + webUri == null ? null : Uri.tryParse(webUri.toString()), + ); }, onLoadStop: (controller, webUri) async { - // Handle load stop ref.read(webViewLoadingProvider.notifier).stop(); - await _handleCompletionUrl( - webUri == null ? null : Uri.tryParse(webUri.toString()), + final uri = webUri == null ? null : Uri.tryParse(webUri.toString()); + var documentLength = -1; + try { + final rawLength = await controller.evaluateJavascript( + source: + "(document.documentElement && document.documentElement.outerHTML || '').length", + ); + final parsedLength = rawLength is num + ? rawLength.toInt() + : int.tryParse(rawLength.toString()); + if (parsedLength == null) { + _logSmokeEvent( + 'document_error', + uri, + detail: 'reason=invalid_document_length', + ); + } else { + documentLength = parsedLength; + } + } catch (_) { + _logSmokeEvent( + 'document_error', + uri, + detail: 'reason=evaluate_javascript_failed', + ); + } + _logSmokeEvent( + 'load_stop', + uri, + detail: 'document_length=$documentLength', ); + await _handleCompletionUrl(uri); }, onReceivedError: (_, webResourceRequest, error) async { - // Handle received error - appLogger.error("Received error: $error"); - // Handle load stop - ref.read(webViewLoadingProvider.notifier).stop(); - await _handleCompletionUrl( + final uri = Uri.tryParse(webResourceRequest.url.toString()); + final isMainFrame = webResourceRequest.isForMainFrame == true; + appLogger.error( + 'WebView request failed: type=${error.type} ' + 'main_frame=$isMainFrame host=${uri?.host ?? ''}', + ); + _logSmokeEvent( + isMainFrame ? 'navigation_error' : 'resource_error', + uri, + detail: 'error_type=${error.type}', + ); + if (isMainFrame) { + ref.read(webViewLoadingProvider.notifier).stop(); + await _handleCompletionUrl(uri); + } + }, + onReceivedHttpError: (_, webResourceRequest, errorResponse) async { + if (webResourceRequest.isForMainFrame != true) return; + _logSmokeEvent( + 'navigation_error', Uri.tryParse(webResourceRequest.url.toString()), + detail: 'http_status=${errorResponse.statusCode}', ); }, ); } + void _logSmokeEvent(String event, Uri? uri, {String detail = ''}) { + if (!PlatformUtils.isWindows || AppBuildInfo.buildType != 'nightly') { + return; + } + // Checkout paths and query strings can contain session tokens. The origin + // is enough to prove which provider loaded without putting them in CI logs. + final safeUri = uri == null + ? '' + : uri.replace(path: '', query: '', fragment: '').toString(); + final suffix = detail.isEmpty ? '' : ' $detail'; + appLogger.info( + 'PAYMENT_WEBVIEW_SMOKE event=$event host=${uri?.host ?? ''} ' + 'url=$safeUri$suffix', + ); + } + bool isLanternHost(String host) => host == 'lantern.io' || host == 'www.lantern.io'; diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index f5d988b6eb..f93ff11920 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -476,6 +476,7 @@ class PaymentCheckoutMethods extends HookConsumerWidget { padding: EdgeInsets.zero, itemBuilder: (context, index) { final method = providers[index]; + final providerName = method.providers.name.toTitleCase(); return Padding( padding: const EdgeInsets.only(bottom: 16), child: ExpansionTile( @@ -498,15 +499,20 @@ class PaymentCheckoutMethods extends HookConsumerWidget { horizontal: defaultSize, vertical: defaultSize, ), - title: Row( - children: [ - Text( - method.method.replaceAll('-', " ").toTitleCase(), - style: theme.titleMedium, - ), - SizedBox(width: defaultSize), - LogsPath(logoPaths: method.providers.icons), - ], + title: Semantics( + identifier: 'payment-provider-${method.providers.name}', + label: '$providerName payment method', + excludeSemantics: true, + child: Row( + children: [ + Text( + method.method.replaceAll('-', " ").toTitleCase(), + style: theme.titleMedium, + ), + SizedBox(width: defaultSize), + LogsPath(logoPaths: method.providers.icons), + ], + ), ), children: [ Row( @@ -591,14 +597,19 @@ class PaymentCheckoutMethods extends HookConsumerWidget { style: theme.bodySmall!.copyWith(color: context.textDisabled), ), SizedBox(height: defaultSize), - PrimaryButton( - label: method.providers.supportSubscription - ? 'subscribe'.i18n - : 'checkout'.i18n, - enabled: !isSubmitting, - onPressed: () { - onSubscribe.call(method); - }, + Semantics( + identifier: 'payment-checkout-${method.providers.name}', + label: 'Continue with $providerName', + excludeSemantics: true, + child: PrimaryButton( + label: method.providers.supportSubscription + ? 'subscribe'.i18n + : 'checkout'.i18n, + enabled: !isSubmitting, + onPressed: () { + onSubscribe.call(method); + }, + ), ), ], ), diff --git a/lib/lantern_app.dart b/lib/lantern_app.dart index e51555493e..95e91d5f8c 100644 --- a/lib/lantern_app.dart +++ b/lib/lantern_app.dart @@ -4,14 +4,17 @@ import 'dart:ui'; import 'package:app_links/app_links.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:i18n_extension/i18n_extension.dart'; import 'package:lantern/core/localization/localization_constants.dart'; import 'package:lantern/core/router/router.dart'; +import 'package:lantern/core/smoke/payment_checkout_smoke.dart'; import 'package:lantern/core/widgets/loading_indicator.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/plans/provider/plans_notifier.dart'; import 'package:lantern/features/window/window_wrapper.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:loader_overlay/loader_overlay.dart'; @@ -25,7 +28,9 @@ final globalRouter = sl(); final RouteObserver routeObserver = RouteObserver(); class LanternApp extends StatefulHookConsumerWidget { - const LanternApp({super.key}); + final PaymentCheckoutSmokeConfig? paymentCheckoutSmoke; + + const LanternApp({super.key, this.paymentCheckoutSmoke}); @override ConsumerState createState() => _LanternAppState(); @@ -35,6 +40,7 @@ class _LanternAppState extends ConsumerState with WidgetsBindingObserver { late final AppLifecycleListener _lifecycle; StreamSubscription? _deepLinkSubscription; + SemanticsHandle? _paymentSmokeSemantics; Uri? _lastHandledUri; DateTime? _lastHandledTime; @@ -44,6 +50,69 @@ class _LanternAppState extends ConsumerState WidgetsBinding.instance.addObserver(this); initDeepLinks(); initLifecycleListener(); + if (widget.paymentCheckoutSmoke != null) { + // The Windows runner drives this screen through MSAA, so keep Flutter's + // semantic tree alive for the duration of the smoke. + _paymentSmokeSemantics = WidgetsBinding.instance.ensureSemantics(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_openPaymentCheckoutSmoke(widget.paymentCheckoutSmoke!)); + }); + } + } + + Future _openPaymentCheckoutSmoke( + PaymentCheckoutSmokeConfig smoke, + ) async { + // Keep plans alive during startup retries. The payment screen starts + // watching them after navigation. + final plansSubscription = ref.listenManual(plansProvider, (_, _) {}); + try { + final planData = await ref.read(plansProvider.future); + final matchingProviders = planData.providers.desktop.where( + (provider) => provider.providers.name == smoke.provider, + ); + if (matchingProviders.isEmpty) { + throw StateError( + 'Provider ${smoke.provider} is absent from staging desktop plans', + ); + } + final provider = matchingProviders.first.providers; + final expectedSubscription = smoke.provider == 'stripe'; + if (provider.supportSubscription != expectedSubscription) { + throw StateError( + 'Provider ${smoke.provider} has unexpected subscription capability', + ); + } + if (planData.plans.isEmpty) { + throw StateError('Staging returned no payment plans'); + } + + final selectedPlan = planData.plans.firstWhere( + (plan) => plan.bestValue, + orElse: () => planData.plans.first, + ); + ref.read(plansProvider.notifier).setSelectedPlan(selectedPlan); + appLogger.info( + 'PAYMENT_CHECKOUT_SMOKE event=ready provider=${smoke.provider} ' + 'run_id=${smoke.runID} plan=${selectedPlan.id} ' + 'billing=${expectedSubscription ? 'subscription' : 'one_time'}', + ); + await appRouter.replaceAll([ + ChoosePaymentMethod( + email: smoke.email, + authFlow: AuthFlow.renewSubscription, + ), + ]); + } catch (error, stackTrace) { + appLogger.error( + 'PAYMENT_CHECKOUT_SMOKE event=bootstrap_error ' + 'provider=${smoke.provider} error=$error', + error, + stackTrace, + ); + } finally { + plansSubscription.close(); + } } void initLifecycleListener() { @@ -64,6 +133,7 @@ class _LanternAppState extends ConsumerState WidgetsBinding.instance.removeObserver(this); _lifecycle.dispose(); _deepLinkSubscription?.cancel(); + _paymentSmokeSemantics?.dispose(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index da502724ea..62cfb2b625 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; @@ -10,6 +11,7 @@ import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/desktop/desktop_window.dart'; import 'package:lantern/core/services/injection_container.dart'; +import 'package:lantern/core/smoke/payment_checkout_smoke.dart'; import 'package:lantern/core/updater/updater.dart'; import 'package:lantern/core/utils/storage_utils.dart'; import 'package:lantern/lantern_app.dart'; @@ -17,7 +19,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; -Future main() async { +Future main([List arguments = const []]) async { WidgetsFlutterBinding.ensureInitialized(); // Timezone is only needed for notification scheduling (data-cap alerts), @@ -39,6 +41,15 @@ Future main() async { final flutterLog = await AppStorageUtils.flutterLogFile(); initLogger(flutterLog.path); appLogger.debug('Starting app initialization...'); + if (PlatformUtils.isWindows && AppBuildInfo.buildType == 'nightly') { + // The Windows runner sets this before Dart starts. If it is unset, + // WebView2 did not get its per-user folder. + appLogger.info( + 'WEBVIEW2_DIAGNOSTIC user_data_folder=' + '${Platform.environment['WEBVIEW2_USER_DATA_FOLDER'] ?? ''} ' + 'local_app_data=${Platform.environment['LOCALAPPDATA'] ?? ''}', + ); + } unawaited(_logDeviceInfo()); await _loadAppSecrets(); appLogger.debug('Injecting services...'); @@ -64,10 +75,33 @@ Future main() async { appLogger.error('Failed to start Updater.init', e, st); } + PaymentCheckoutSmokeConfig? paymentCheckoutSmoke; + try { + paymentCheckoutSmoke = PaymentCheckoutSmokeConfig.parse( + arguments, + isWindows: PlatformUtils.isWindows, + buildType: AppBuildInfo.buildType, + ); + if (paymentCheckoutSmoke != null) { + appLogger.info( + 'PAYMENT_CHECKOUT_SMOKE event=enabled ' + 'provider=${paymentCheckoutSmoke.provider} ' + 'run_id=${paymentCheckoutSmoke.runID}', + ); + } + } on FormatException catch (error, stackTrace) { + appLogger.error( + 'PAYMENT_CHECKOUT_SMOKE event=rejected error=$error', + error, + stackTrace, + ); + rethrow; + } + runApp( ProviderScope( retry: (retryCount, error) => null, - child: const LanternApp(), + child: LanternApp(paymentCheckoutSmoke: paymentCheckoutSmoke), ), ); } diff --git a/test/core/smoke/payment_checkout_smoke_test.dart b/test/core/smoke/payment_checkout_smoke_test.dart new file mode 100644 index 0000000000..7006e84e05 --- /dev/null +++ b/test/core/smoke/payment_checkout_smoke_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/smoke/payment_checkout_smoke.dart'; + +void main() { + group('PaymentCheckoutSmokeConfig', () { + test('does nothing without smoke arguments', () { + expect( + PaymentCheckoutSmokeConfig.parse( + const [], + isWindows: true, + buildType: 'nightly', + ), + isNull, + ); + }); + + test('accepts a constrained provider and derives the E2E email', () { + final config = PaymentCheckoutSmokeConfig.parse( + const [ + '--payment-checkout-smoke=stripe', + '--payment-checkout-run-id=9a1632f8-5b33-4d6f-8a42-7a8a4f77d829', + ], + isWindows: true, + buildType: 'nightly', + ); + + expect(config?.provider, 'stripe'); + expect( + config?.email, + 'e2e+9a1632f8-5b33-4d6f-8a42-7a8a4f77d829@getlantern.org', + ); + }); + + test('rejects the hook outside a Windows nightly build', () { + expect( + () => PaymentCheckoutSmokeConfig.parse( + const [ + '--payment-checkout-smoke=stripe', + '--payment-checkout-run-id=9a1632f8-5b33-4d6f-8a42-7a8a4f77d829', + ], + isWindows: true, + buildType: 'production', + ), + throwsFormatException, + ); + }); + + test('rejects arbitrary providers and malformed run IDs', () { + expect( + () => PaymentCheckoutSmokeConfig.parse( + const [ + '--payment-checkout-smoke=https://example.com', + '--payment-checkout-run-id=9a1632f8-5b33-4d6f-8a42-7a8a4f77d829', + ], + isWindows: true, + buildType: 'nightly', + ), + throwsFormatException, + ); + expect( + () => PaymentCheckoutSmokeConfig.parse( + const [ + '--payment-checkout-smoke=shepherd', + '--payment-checkout-run-id=../../unsafe', + ], + isWindows: true, + buildType: 'nightly', + ), + throwsFormatException, + ); + expect( + () => PaymentCheckoutSmokeConfig.parse( + const [ + '--payment-checkout-smoke=', + '--payment-checkout-run-id=9a1632f8-5b33-4d6f-8a42-7a8a4f77d829', + ], + isWindows: true, + buildType: 'nightly', + ), + throwsFormatException, + ); + }); + }); +}