diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml new file mode 100644 index 000000000..23b8028f0 --- /dev/null +++ b/.github/workflows/release-smoke-test.yml @@ -0,0 +1,77 @@ +name: Release Smoke Test + +on: + pull_request: + branches: [ "main" ] + paths: + - '.github/workflows/release-smoke-test.yml' + - 'testing/release-smoke/**' + workflow_dispatch: + inputs: + release-repository: + description: 'GitHub repository to download the release from' + required: true + default: 'Devolutions/UniGetUI' + release-tag: + description: 'Release tag to test. Leave empty for the latest release.' + required: false + default: '' + installer-asset-name: + description: 'Installer asset name in the GitHub release' + required: true + default: 'UniGetUI.Installer.x64.exe' + max-stage: + description: 'Last smoke-test stage to run' + required: true + type: choice + default: 'install-launch' + options: + - rdp + - rdp-client + - remoting-server + - remote-command + - install-launch + +jobs: + release-smoke-test: + name: Windows release smoke test + runs-on: windows-latest + timeout-minutes: 90 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run release smoke test + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_REPOSITORY: ${{ github.event.inputs['release-repository'] || 'Devolutions/UniGetUI' }} + RELEASE_TAG: ${{ github.event.inputs['release-tag'] || '' }} + INSTALLER_ASSET_NAME: ${{ github.event.inputs['installer-asset-name'] || 'UniGetUI.Installer.x64.exe' }} + MAX_STAGE: ${{ github.event.inputs['max-stage'] || 'install-launch' }} + run: | + .\testing\release-smoke\Invoke-ReleaseSmokeTest.ps1 ` + -ReleaseRepository $env:RELEASE_REPOSITORY ` + -ReleaseTag $env:RELEASE_TAG ` + -InstallerAssetName $env:INSTALLER_ASSET_NAME ` + -MaxStage $env:MAX_STAGE ` + -ArtifactsDir '${{ github.workspace }}\artifacts\release-smoke' + + - name: Cleanup release smoke test + if: always() + shell: pwsh + run: | + .\testing\release-smoke\Invoke-ReleaseSmokeTest.ps1 ` + -CleanupOnly ` + -ArtifactsDir '${{ github.workspace }}\artifacts\release-smoke' + + - name: Upload release smoke artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-smoke-test + path: artifacts\release-smoke + if-no-files-found: warn diff --git a/testing/release-smoke/Enable-LocalRdp.ps1 b/testing/release-smoke/Enable-LocalRdp.ps1 new file mode 100644 index 000000000..b89338f03 --- /dev/null +++ b/testing/release-smoke/Enable-LocalRdp.ps1 @@ -0,0 +1,172 @@ +[CmdletBinding(DefaultParameterSetName = 'Enable')] +param( + [Parameter(ParameterSetName = 'Enable')] + [Parameter(ParameterSetName = 'Cleanup')] + [string] $StatePath = (Join-Path $env:RUNNER_TEMP 'unigetui-release-smoke-rdp-state.json'), + + [Parameter(ParameterSetName = 'Cleanup')] + [switch] $Cleanup +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$terminalServerPath = 'HKLM:\System\CurrentControlSet\Control\Terminal Server' +$rdpTcpPath = Join-Path $terminalServerPath 'WinStations\RDP-Tcp' +$rdpGroupName = 'Remote Desktop Users' + +function Get-RegistryValue { + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $Name + ) + + $property = Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $property) { + return $null + } + + return $property.$Name +} + +function Write-JsonFile { + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [object] $Value + ) + + $directory = Split-Path -Path $Path -Parent + New-Item -Path $directory -ItemType Directory -Force | Out-Null + $Value | ConvertTo-Json -Depth 8 | Set-Content -Path $Path -Encoding utf8NoBOM +} + +function Test-TcpPort { + param( + [Parameter(Mandatory)] + [string] $HostName, + + [Parameter(Mandatory)] + [int] $Port + ) + + $client = [System.Net.Sockets.TcpClient]::new() + try { + $connect = $client.BeginConnect($HostName, $Port, $null, $null) + if (-not $connect.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds(1))) { + return $false + } + + $client.EndConnect($connect) + return $true + } + catch { + return $false + } + finally { + $client.Dispose() + } +} + +function Wait-TcpPort { + param( + [Parameter(Mandatory)] + [string] $HostName, + + [Parameter(Mandatory)] + [int] $Port, + + [int] $TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + if (Test-TcpPort -HostName $HostName -Port $Port) { + return + } + + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for $HostName`:$Port to accept TCP connections." +} + +if ($Cleanup) { + if (-not (Test-Path $StatePath)) { + return + } + + $state = Get-Content -Path $StatePath -Raw | ConvertFrom-Json + + if ($null -ne $state.fDenyTSConnections) { + Set-ItemProperty -Path $terminalServerPath -Name 'fDenyTSConnections' -Value ([int] $state.fDenyTSConnections) + } + + if ($null -ne $state.UserAuthentication) { + Set-ItemProperty -Path $rdpTcpPath -Name 'UserAuthentication' -Value ([int] $state.UserAuthentication) + } + + foreach ($rule in @($state.FirewallRules)) { + if ($null -ne $rule.Name -and $null -ne $rule.Enabled) { + Set-NetFirewallRule -Name $rule.Name -Enabled $rule.Enabled -ErrorAction SilentlyContinue + } + } + + if ($state.AddedToRemoteDesktopUsers) { + Remove-LocalGroupMember -Group $rdpGroupName -Member $state.LocalUserName -ErrorAction SilentlyContinue + } + + Remove-Item -Path $StatePath -Force -ErrorAction SilentlyContinue + return +} + +$localUserName = $env:USERNAME +if ([string]::IsNullOrWhiteSpace($localUserName)) { + throw 'USERNAME is not set; cannot configure a local RDP user.' +} + +$passwordBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(24) +$temporaryPassword = 'RdpSmoke!' + [Convert]::ToBase64String($passwordBytes) + 'aA1!' +Write-Host "::add-mask::$temporaryPassword" + +$currentMembers = @(Get-LocalGroupMember -Group $rdpGroupName -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) +$memberNames = @($localUserName, "$env:COMPUTERNAME\$localUserName") +$wasRdpMember = [bool]($currentMembers | Where-Object { $memberNames -contains $_ } | Select-Object -First 1) + +$state = [pscustomobject]@{ + LocalUserName = $localUserName + DomainUserName = "$env:COMPUTERNAME\$localUserName" + fDenyTSConnections = Get-RegistryValue -Path $terminalServerPath -Name 'fDenyTSConnections' + UserAuthentication = Get-RegistryValue -Path $rdpTcpPath -Name 'UserAuthentication' + FirewallRules = @(Get-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue | Select-Object -Property Name, Enabled) + AddedToRemoteDesktopUsers = (-not $wasRdpMember) +} +Write-JsonFile -Path $StatePath -Value $state + +$securePassword = ConvertTo-SecureString -String $temporaryPassword -AsPlainText -Force +Set-LocalUser -Name $localUserName -Password $securePassword + +if (-not $wasRdpMember) { + Add-LocalGroupMember -Group $rdpGroupName -Member $localUserName +} + +Set-ItemProperty -Path $terminalServerPath -Name 'fDenyTSConnections' -Value 0 +Set-ItemProperty -Path $rdpTcpPath -Name 'UserAuthentication' -Value 0 +Set-Service -Name TermService -StartupType Automatic +Start-Service -Name TermService +Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' | Out-Null +Wait-TcpPort -HostName '127.0.0.1' -Port 3389 + +[pscustomobject]@{ + UserName = $localUserName + DomainUserName = "$env:COMPUTERNAME\$localUserName" + Password = $temporaryPassword + HostName = '127.0.0.1' + Port = 3389 + StatePath = $StatePath +} | ConvertTo-Json -Compress diff --git a/testing/release-smoke/Install-AndLaunchRelease.ps1 b/testing/release-smoke/Install-AndLaunchRelease.ps1 new file mode 100644 index 000000000..8280b527c --- /dev/null +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -0,0 +1,500 @@ +[CmdletBinding()] +param( + [string] $ReleaseRepository = 'Devolutions/UniGetUI', + + [string] $ReleaseTag = '', + + [string] $InstallerAssetName = 'UniGetUI.Installer.x64.exe', + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke'), + + [string] $GitHubToken = $env:GITHUB_TOKEN +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-GitHubApi { + param( + [Parameter(Mandatory)] + [string] $Uri + ) + + $headers = @{ + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + if (-not [string]::IsNullOrWhiteSpace($GitHubToken)) { + $headers['Authorization'] = "Bearer $GitHubToken" + } + + Invoke-RestMethod -Uri $Uri -Headers $headers +} + +function Download-ReleaseAsset { + param( + [Parameter(Mandatory)] + [string] $Uri, + + [Parameter(Mandatory)] + [string] $DestinationPath + ) + + $headers = @{} + if (-not [string]::IsNullOrWhiteSpace($GitHubToken)) { + $headers['Authorization'] = "Bearer $GitHubToken" + } + + Invoke-WebRequest -Uri $Uri -Headers $headers -OutFile $DestinationPath +} + +function Initialize-ScreenshotSupport { + if (-not ('ReleaseSmokeNativeMethods' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class ReleaseSmokeNativeMethods +{ + [StructLayout(LayoutKind.Sequential)] + public struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + public struct DEVMODE + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmDeviceName; + public short dmSpecVersion; + public short dmDriverVersion; + public short dmSize; + public short dmDriverExtra; + public int dmFields; + public int dmPositionX; + public int dmPositionY; + public int dmDisplayOrientation; + public int dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmFormName; + public short dmLogPixels; + public int dmBitsPerPel; + public int dmPelsWidth; + public int dmPelsHeight; + public int dmDisplayFlags; + public int dmDisplayFrequency; + public int dmICMMethod; + public int dmICMIntent; + public int dmMediaType; + public int dmDitherType; + public int dmReserved1; + public int dmReserved2; + public int dmPanningWidth; + public int dmPanningHeight; + } + + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("gdi32.dll")] + public static extern bool BitBlt(IntPtr hdcDest, int xDest, int yDest, int width, int height, IntPtr hdcSrc, int xSrc, int ySrc, int rasterOperation); + + [DllImport("user32.dll")] + public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); + + [DllImport("gdi32.dll")] + public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + public static extern bool EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + public static extern int ChangeDisplaySettings(ref DEVMODE devMode, int flags); +} +'@ + } + + [ReleaseSmokeNativeMethods]::SetProcessDPIAware() | Out-Null + + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing +} + +function Set-InteractiveDisplayResolution { + param( + [Parameter(Mandatory)] + [int] $Width, + + [Parameter(Mandatory)] + [int] $Height + ) + + Initialize-ScreenshotSupport + + $currentSettings = -1 + $devMode = New-Object 'ReleaseSmokeNativeMethods+DEVMODE' + $devMode.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf([type] 'ReleaseSmokeNativeMethods+DEVMODE') + + if (-not [ReleaseSmokeNativeMethods]::EnumDisplaySettings($null, $currentSettings, [ref] $devMode)) { + throw 'EnumDisplaySettings failed while reading the current RDP display mode.' + } + + $devMode.dmPelsWidth = $Width + $devMode.dmPelsHeight = $Height + $devMode.dmFields = 0x180000 + + $result = [ReleaseSmokeNativeMethods]::ChangeDisplaySettings([ref] $devMode, 0) + if ($result -ne 0) { + throw "ChangeDisplaySettings failed while setting ${Width}x${Height}; result=$result." + } +} + +function Get-PhysicalDesktopBounds { + Initialize-ScreenshotSupport + + $sourceDc = [ReleaseSmokeNativeMethods]::GetDC([IntPtr]::Zero) + if ($sourceDc -eq [IntPtr]::Zero) { + throw 'GetDC failed while reading desktop dimensions.' + } + + try { + $desktopHorzRes = 118 + $desktopVertRes = 117 + $horzRes = 8 + $vertRes = 10 + + $width = [ReleaseSmokeNativeMethods]::GetDeviceCaps($sourceDc, $desktopHorzRes) + $height = [ReleaseSmokeNativeMethods]::GetDeviceCaps($sourceDc, $desktopVertRes) + + if ($width -le 0 -or $height -le 0) { + $width = [ReleaseSmokeNativeMethods]::GetDeviceCaps($sourceDc, $horzRes) + $height = [ReleaseSmokeNativeMethods]::GetDeviceCaps($sourceDc, $vertRes) + } + + if ($width -le 0 -or $height -le 0) { + throw "Desktop device dimensions are invalid: width=$width, height=$height." + } + + [pscustomobject]@{ + Left = 0 + Top = 0 + Width = $width + Height = $height + } + } + finally { + [ReleaseSmokeNativeMethods]::ReleaseDC([IntPtr]::Zero, $sourceDc) | Out-Null + } +} + +function Get-DisplayMetrics { + Initialize-ScreenshotSupport + + $physicalBounds = Get-PhysicalDesktopBounds + $virtualBounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + + [pscustomobject]@{ + PhysicalWidth = $physicalBounds.Width + PhysicalHeight = $physicalBounds.Height + VirtualWidth = $virtualBounds.Width + VirtualHeight = $virtualBounds.Height + VirtualLeft = $virtualBounds.Left + VirtualTop = $virtualBounds.Top + } +} + +function Wait-UniGetUIWindow { + param( + [Parameter(Mandatory)] + [int] $SessionId, + + [int] $TimeoutSeconds = 90 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $process = Get-Process -Name UniGetUI -ErrorAction SilentlyContinue | + Where-Object { $_.SessionId -eq $SessionId -and $_.MainWindowHandle -ne [IntPtr]::Zero } | + Sort-Object -Property StartTime -Descending | + Select-Object -First 1 + + if ($null -ne $process) { + return $process + } + + Start-Sleep -Seconds 2 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for a UniGetUI window in interactive session $SessionId." +} + +function Save-DesktopScreenshot { + param( + [Parameter(Mandatory)] + [string] $Path + ) + + Initialize-ScreenshotSupport + + $directory = Split-Path -Path $Path -Parent + New-Item -Path $directory -ItemType Directory -Force | Out-Null + + $bounds = Get-PhysicalDesktopBounds + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $sourceDc = [ReleaseSmokeNativeMethods]::GetDC([IntPtr]::Zero) + if ($sourceDc -eq [IntPtr]::Zero) { + throw 'GetDC failed while capturing the desktop.' + } + + $targetDc = $graphics.GetHdc() + try { + $srccopy = 0x00CC0020 + if (-not [ReleaseSmokeNativeMethods]::BitBlt($targetDc, 0, 0, $bounds.Width, $bounds.Height, $sourceDc, $bounds.Left, $bounds.Top, $srccopy)) { + throw "BitBlt failed while capturing the desktop." + } + } + finally { + $graphics.ReleaseHdc($targetDc) + [ReleaseSmokeNativeMethods]::ReleaseDC([IntPtr]::Zero, $sourceDc) | Out-Null + } + + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +function Save-WindowScreenshot { + param( + [Parameter(Mandatory)] + [IntPtr] $WindowHandle, + + [Parameter(Mandatory)] + [string] $Path + ) + + Initialize-ScreenshotSupport + + $directory = Split-Path -Path $Path -Parent + New-Item -Path $directory -ItemType Directory -Force | Out-Null + + [ReleaseSmokeNativeMethods]::ShowWindow($WindowHandle, 9) | Out-Null + [ReleaseSmokeNativeMethods]::SetForegroundWindow($WindowHandle) | Out-Null + Start-Sleep -Seconds 3 + + $rect = New-Object 'ReleaseSmokeNativeMethods+RECT' + if (-not [ReleaseSmokeNativeMethods]::GetWindowRect($WindowHandle, [ref] $rect)) { + throw "Could not read UniGetUI window bounds for handle $WindowHandle." + } + + $width = $rect.Right - $rect.Left + $height = $rect.Bottom - $rect.Top + if ($width -le 0 -or $height -le 0) { + throw "UniGetUI window bounds are invalid: left=$($rect.Left), top=$($rect.Top), right=$($rect.Right), bottom=$($rect.Bottom)." + } + + $bitmap = [System.Drawing.Bitmap]::new($width, $height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $targetDc = $graphics.GetHdc() + try { + if (-not [ReleaseSmokeNativeMethods]::PrintWindow($WindowHandle, $targetDc, 2)) { + $sourceDc = [ReleaseSmokeNativeMethods]::GetDC([IntPtr]::Zero) + if ($sourceDc -eq [IntPtr]::Zero) { + throw "GetDC failed while capturing the UniGetUI window." + } + + try { + $srccopy = 0x00CC0020 + if (-not [ReleaseSmokeNativeMethods]::BitBlt($targetDc, 0, 0, $width, $height, $sourceDc, $rect.Left, $rect.Top, $srccopy)) { + throw "BitBlt failed while capturing the UniGetUI window." + } + } + finally { + [ReleaseSmokeNativeMethods]::ReleaseDC([IntPtr]::Zero, $sourceDc) | Out-Null + } + } + } + finally { + $graphics.ReleaseHdc($targetDc) + } + + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +$targetDesktopWidth = 800 +$targetDesktopHeight = 600 +$displayBefore = Get-DisplayMetrics +try { + Set-InteractiveDisplayResolution -Width $targetDesktopWidth -Height $targetDesktopHeight + Start-Sleep -Seconds 3 +} +catch { + Write-Warning "Could not change the interactive display to ${targetDesktopWidth}x${targetDesktopHeight}: $($_.Exception.Message)" +} +$displayAfter = Get-DisplayMetrics + +if ($displayAfter.PhysicalWidth -ne $targetDesktopWidth -or $displayAfter.PhysicalHeight -ne $targetDesktopHeight) { + Write-Warning "RDP desktop is $($displayAfter.PhysicalWidth)x$($displayAfter.PhysicalHeight), requested ${targetDesktopWidth}x${targetDesktopHeight}." +} + +[pscustomobject]@{ + RequestedWidth = $targetDesktopWidth + RequestedHeight = $targetDesktopHeight + Before = $displayBefore + After = $displayAfter +} | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $ArtifactsDir 'display-metrics.json') -Encoding utf8NoBOM + +$downloadsDir = Join-Path $ArtifactsDir 'downloads' +New-Item -Path $downloadsDir -ItemType Directory -Force | Out-Null + +$escapedRepository = $ReleaseRepository.Trim('/') +if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { + $release = Invoke-GitHubApi -Uri "https://api.github.com/repos/$escapedRepository/releases/latest" +} +else { + $release = Invoke-GitHubApi -Uri "https://api.github.com/repos/$escapedRepository/releases/tags/$([System.Uri]::EscapeDataString($ReleaseTag))" +} + +$installerAsset = $release.assets | Where-Object { $_.name -eq $InstallerAssetName } | Select-Object -First 1 +if (-not $installerAsset) { + throw "Could not find installer asset '$InstallerAssetName' in release $($release.tag_name)." +} + +$checksumsAsset = $release.assets | Where-Object { $_.name -eq 'checksums.txt' } | Select-Object -First 1 +if (-not $checksumsAsset) { + throw "Could not find checksums.txt in release $($release.tag_name)." +} + +$installerPath = Join-Path $downloadsDir $installerAsset.name +$checksumsPath = Join-Path $downloadsDir $checksumsAsset.name +Download-ReleaseAsset -Uri $installerAsset.browser_download_url -DestinationPath $installerPath +Download-ReleaseAsset -Uri $checksumsAsset.browser_download_url -DestinationPath $checksumsPath + +$checksumPattern = "^(?[A-Fa-f0-9]{64})\s+$([regex]::Escape($installerAsset.name))$" +$checksumLine = Select-String -Path $checksumsPath -Pattern $checksumPattern | Select-Object -First 1 +if (-not $checksumLine) { + throw "Could not find SHA256 for '$($installerAsset.name)' in $checksumsPath." +} + +$expectedHash = $checksumLine.Matches[0].Groups['hash'].Value.ToUpperInvariant() +$actualHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToUpperInvariant() +if ($actualHash -ne $expectedHash) { + throw "SHA256 mismatch for $installerPath. Expected $expectedHash but got $actualHash." +} + +$installDir = Join-Path $env:LOCALAPPDATA 'Programs\UniGetUI' +$installerLogPath = Join-Path $ArtifactsDir 'unigetui-installer.log' +$installArguments = @( + '/VERYSILENT', + '/SUPPRESSMSGBOXES', + '/NORESTART', + '/CURRENTUSER', + '/TASKS=regularinstall', + "/DIR=$installDir", + '/NoAutoStart', + '/NoRunOnStartup', + "/LOG=$installerLogPath" +) + +$installerProcess = Start-Process -FilePath $installerPath -ArgumentList $installArguments -Wait -PassThru +if ($installerProcess.ExitCode -notin @(0, 3010)) { + throw "UniGetUI installer failed with exit code $($installerProcess.ExitCode). See $installerLogPath." +} + +$exeCandidates = @( + (Join-Path $installDir 'UniGetUI.exe'), + (Join-Path $env:ProgramFiles 'UniGetUI\UniGetUI.exe') +) + +$installedExe = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $installedExe) { + throw "Could not locate installed UniGetUI.exe. Checked: $($exeCandidates -join ', ')" +} + +$launchProcess = Start-Process -FilePath $installedExe -PassThru +Start-Sleep -Seconds 20 + +$currentSessionId = (Get-Process -Id $PID).SessionId +$screenshotsDir = Join-Path $ArtifactsDir 'screenshots' +$desktopScreenshotPath = Join-Path $screenshotsDir 'unigetui-desktop.png' +$windowScreenshotPath = Join-Path $screenshotsDir 'unigetui-window.png' +$failureScreenshotPath = Join-Path $screenshotsDir 'unigetui-launch-failure-desktop.png' + +try { + $runningProcesses = @(Get-Process -Name UniGetUI -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq $currentSessionId }) + if ($runningProcesses.Count -eq 0) { + throw "UniGetUI did not remain running in interactive session $currentSessionId." + } + + $windowProcess = Wait-UniGetUIWindow -SessionId $currentSessionId + Save-DesktopScreenshot -Path $desktopScreenshotPath + Save-WindowScreenshot -WindowHandle $windowProcess.MainWindowHandle -Path $windowScreenshotPath +} +catch { + try { + Save-DesktopScreenshot -Path $failureScreenshotPath + Write-Warning "Captured failure desktop screenshot at $failureScreenshotPath" + } + catch { + Write-Warning "Could not capture failure desktop screenshot: $($_.Exception.Message)" + } + + throw +} + +$result = [pscustomobject]@{ + ReleaseTag = $release.tag_name + InstallerAssetName = $installerAsset.name + InstallerPath = $installerPath + InstallerSha256 = $actualHash + InstallDir = $installDir + InstalledExe = $installedExe + LauncherProcessId = $launchProcess.Id + RunningProcessIds = @($runningProcesses | ForEach-Object { $_.Id }) + WindowProcessId = $windowProcess.Id + MainWindowHandle = $windowProcess.MainWindowHandle.ToInt64() + SessionId = $currentSessionId + Screenshots = [pscustomobject]@{ + Desktop = $desktopScreenshotPath + Window = $windowScreenshotPath + } +} + +$result | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $ArtifactsDir 'unigetui-launch.json') -Encoding utf8NoBOM +$result diff --git a/testing/release-smoke/Invoke-InteractiveCommand.ps1 b/testing/release-smoke/Invoke-InteractiveCommand.ps1 new file mode 100644 index 000000000..4a7b10348 --- /dev/null +++ b/testing/release-smoke/Invoke-InteractiveCommand.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('Verify', 'InstallLaunch')] + [string] $Mode, + + [string] $EndpointPath = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke\pshost-endpoint.json'), + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke'), + + [string] $ReleaseRepository = 'Devolutions/UniGetUI', + + [string] $ReleaseTag = '', + + [string] $InstallerAssetName = 'UniGetUI.Installer.x64.exe', + + [string] $GitHubToken = $env:GITHUB_TOKEN +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $EndpointPath)) { + throw "Endpoint marker was not found: $EndpointPath" +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +Import-Module AwakeCoding.PSRemoting -ErrorAction Stop + +$endpoint = Get-Content -Path $EndpointPath -Raw | ConvertFrom-Json +$session = New-PSHostSession -HostName $endpoint.HostName -Port ([int] $endpoint.Port) + +try { + if ($Mode -eq 'Verify') { + $result = Invoke-Command -Session $session -ScriptBlock { + $process = Get-Process -Id $PID + $notepad = Start-Process -FilePath (Join-Path $env:WINDIR 'System32\notepad.exe') -PassThru + Start-Sleep -Seconds 3 + $notepadProcess = Get-Process -Id $notepad.Id -ErrorAction Stop + Stop-Process -Id $notepad.Id -Force + + [pscustomobject]@{ + ProcessId = $PID + SessionId = $process.SessionId + UserName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + UserInteractive = [Environment]::UserInteractive + Desktop = $env:USERPROFILE + GuiProbeProcessId = $notepadProcess.Id + GuiProbeSessionId = $notepadProcess.SessionId + } + } + + if (-not $result.UserInteractive) { + throw 'Remote command did not report an interactive user session.' + } + + $result | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $ArtifactsDir 'remote-command-verification.json') -Encoding utf8NoBOM + $result | ConvertTo-Json -Compress + return + } + + $installScriptPath = Join-Path $PSScriptRoot 'Install-AndLaunchRelease.ps1' + $result = Invoke-Command -Session $session -ScriptBlock { + param( + [string] $ScriptPath, + [string] $RemoteReleaseRepository, + [string] $RemoteReleaseTag, + [string] $RemoteInstallerAssetName, + [string] $RemoteArtifactsDir, + [string] $RemoteGitHubToken + ) + + & $ScriptPath ` + -ReleaseRepository $RemoteReleaseRepository ` + -ReleaseTag $RemoteReleaseTag ` + -InstallerAssetName $RemoteInstallerAssetName ` + -ArtifactsDir $RemoteArtifactsDir ` + -GitHubToken $RemoteGitHubToken + } -ArgumentList $installScriptPath, $ReleaseRepository, $ReleaseTag, $InstallerAssetName, $ArtifactsDir, $GitHubToken + + $result | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $ArtifactsDir 'install-launch-result.json') -Encoding utf8NoBOM + $result | ConvertTo-Json -Compress +} +finally { + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +} diff --git a/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 b/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 new file mode 100644 index 000000000..e40629a75 --- /dev/null +++ b/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 @@ -0,0 +1,165 @@ +[CmdletBinding()] +param( + [ValidateSet('rdp', 'rdp-client', 'remoting-server', 'remote-command', 'install-launch')] + [string] $MaxStage = 'install-launch', + + [string] $ReleaseRepository = 'Devolutions/UniGetUI', + + [string] $ReleaseTag = '', + + [string] $InstallerAssetName = 'UniGetUI.Installer.x64.exe', + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke'), + + [switch] $CleanupOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$stageOrder = @('rdp', 'rdp-client', 'remoting-server', 'remote-command', 'install-launch') +$maxStageIndex = [array]::IndexOf($stageOrder, $MaxStage) +$scriptsRoot = $PSScriptRoot +$secretsDir = Join-Path $env:RUNNER_TEMP 'unigetui-release-smoke-secrets' +$rdpStatePath = Join-Path $ArtifactsDir 'rdp-state.json' +$rdpClientStatePath = Join-Path $ArtifactsDir 'rdp-client-state.json' +$endpointPath = Join-Path $ArtifactsDir 'pshost-endpoint.json' +$taskName = 'UniGetUIReleaseSmokePSHost' +$psHostPort = 45985 + +function Test-ShouldRunStage { + param( + [Parameter(Mandatory)] + [string] $Stage + ) + + return ([array]::IndexOf($stageOrder, $Stage) -le $maxStageIndex) +} + +function Stop-ProcessFromState { + param( + [Parameter(Mandatory)] + [string] $Path + ) + + if (-not (Test-Path $Path)) { + return + } + + $state = Get-Content -Path $Path -Raw | ConvertFrom-Json + if ($null -eq $state.ProcessId) { + return + } + + $process = Get-Process -Id ([int] $state.ProcessId) -ErrorAction SilentlyContinue + if ($null -ne $process) { + Stop-Process -Id $process.Id -Force + } +} + +function Invoke-Cleanup { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + + Stop-ProcessFromState -Path $rdpClientStatePath + + if (Test-Path $endpointPath) { + $endpoint = Get-Content -Path $endpointPath -Raw | ConvertFrom-Json + if ($endpoint.PSObject.Properties.Name -contains 'ProcessId') { + $process = Get-Process -Id ([int] $endpoint.ProcessId) -ErrorAction SilentlyContinue + if ($null -ne $process) { + Stop-Process -Id $process.Id -Force + } + } + } + + & (Join-Path $scriptsRoot 'Enable-LocalRdp.ps1') -Cleanup -StatePath $rdpStatePath + Remove-Item -Path $secretsDir -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($CleanupOnly) { + Invoke-Cleanup + return +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +New-Item -Path $secretsDir -ItemType Directory -Force | Out-Null + +$rdpInfo = $null + +try { + if (Test-ShouldRunStage -Stage 'rdp') { + Write-Host '::group::Enable local RDP' + $rdpInfoJson = & (Join-Path $scriptsRoot 'Enable-LocalRdp.ps1') -StatePath $rdpStatePath + $rdpInfo = $rdpInfoJson | ConvertFrom-Json + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'remoting-server') { + Write-Host '::group::Register interactive PSHostServer' + & (Join-Path $scriptsRoot 'Start-InteractivePSHostServer.ps1') ` + -Register ` + -EndpointPath $endpointPath ` + -Port $psHostPort ` + -ArtifactsDir $ArtifactsDir ` + -TaskName $taskName | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'rdp-client') { + if ($null -eq $rdpInfo) { + throw 'RDP stage did not produce connection information.' + } + + Write-Host '::group::Build and start IronRDP client' + & (Join-Path $scriptsRoot 'Start-LocalRdpClient.ps1') ` + -UserName $rdpInfo.DomainUserName ` + -Password $rdpInfo.Password ` + -HostName $rdpInfo.HostName ` + -Port ([int] $rdpInfo.Port) ` + -ArtifactsDir $ArtifactsDir ` + -SecretsDir $secretsDir ` + -StatePath $rdpClientStatePath | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'remoting-server') { + Write-Host '::group::Start interactive PSHostServer task' + Start-ScheduledTask -TaskName $taskName + Start-Sleep -Seconds 5 + Get-ScheduledTask -TaskName $taskName | Get-ScheduledTaskInfo | Format-List | Out-String | Write-Host + Write-Host '::endgroup::' + + Write-Host '::group::Wait for interactive PSHostServer' + & (Join-Path $scriptsRoot 'Start-InteractivePSHostServer.ps1') ` + -Wait ` + -EndpointPath $endpointPath ` + -TimeoutSeconds 180 | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'remote-command') { + Write-Host '::group::Verify interactive remoting' + & (Join-Path $scriptsRoot 'Invoke-InteractiveCommand.ps1') ` + -Mode Verify ` + -EndpointPath $endpointPath ` + -ArtifactsDir $ArtifactsDir | Write-Host + Write-Host '::endgroup::' + } + + if (Test-ShouldRunStage -Stage 'install-launch') { + Write-Host '::group::Install and launch UniGetUI release' + & (Join-Path $scriptsRoot 'Invoke-InteractiveCommand.ps1') ` + -Mode InstallLaunch ` + -EndpointPath $endpointPath ` + -ArtifactsDir $ArtifactsDir ` + -ReleaseRepository $ReleaseRepository ` + -ReleaseTag $ReleaseTag ` + -InstallerAssetName $InstallerAssetName ` + -GitHubToken $env:GITHUB_TOKEN | Write-Host + Write-Host '::endgroup::' + } +} +catch { + Write-Host '::error::Release smoke test failed.' + throw +} diff --git a/testing/release-smoke/Start-InteractivePSHostServer.ps1 b/testing/release-smoke/Start-InteractivePSHostServer.ps1 new file mode 100644 index 000000000..03848520b --- /dev/null +++ b/testing/release-smoke/Start-InteractivePSHostServer.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding(DefaultParameterSetName = 'Register')] +param( + [Parameter(ParameterSetName = 'Register')] + [switch] $Register, + + [Parameter(ParameterSetName = 'RunServer')] + [switch] $RunServer, + + [Parameter(ParameterSetName = 'Wait')] + [switch] $Wait, + + [Parameter(ParameterSetName = 'Register')] + [Parameter(ParameterSetName = 'RunServer')] + [Parameter(ParameterSetName = 'Wait')] + [string] $EndpointPath = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke\pshost-endpoint.json'), + + [Parameter(ParameterSetName = 'Register')] + [Parameter(ParameterSetName = 'RunServer')] + [int] $Port = 45985, + + [Parameter(ParameterSetName = 'Register')] + [Parameter(ParameterSetName = 'RunServer')] + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke'), + + [Parameter(ParameterSetName = 'Register')] + [string] $TaskName = 'UniGetUIReleaseSmokePSHost', + + [Parameter(ParameterSetName = 'Wait')] + [int] $TimeoutSeconds = 120 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Ensure-AwakeCodingModule { + if (Get-Module -ListAvailable -Name AwakeCoding.PSRemoting) { + return + } + + $repository = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue + if ($null -ne $repository -and $repository.InstallationPolicy -ne 'Trusted') { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + } + + $installModuleParameters = @{ + Name = 'AwakeCoding.PSRemoting' + Scope = 'CurrentUser' + Repository = 'PSGallery' + Force = $true + AllowClobber = $true + } + + if ((Get-Command Install-Module).Parameters.ContainsKey('AcceptLicense')) { + $installModuleParameters['AcceptLicense'] = $true + } + + Install-Module @installModuleParameters +} + +if ($Register) { + New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + Remove-Item -Path $EndpointPath -Force -ErrorAction SilentlyContinue + Ensure-AwakeCodingModule + + $pwshPath = (Get-Command pwsh -ErrorAction Stop).Source + $actionArguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$PSCommandPath`"", + '-RunServer', + '-EndpointPath', "`"$EndpointPath`"", + '-Port', $Port, + '-ArtifactsDir', "`"$ArtifactsDir`"" + ) -join ' ' + + $taskAction = New-ScheduledTaskAction -Execute $pwshPath -Argument $actionArguments + $taskTrigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:COMPUTERNAME\$env:USERNAME" + $taskPrincipal = New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\$env:USERNAME" -LogonType Interactive -RunLevel Highest + $taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) + Register-ScheduledTask -TaskName $TaskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Settings $taskSettings -Force | Out-Null + + [pscustomobject]@{ + TaskName = $TaskName + EndpointPath = $EndpointPath + Port = $Port + } | ConvertTo-Json -Compress + return +} + +if ($RunServer) { + New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null + $serverLogPath = Join-Path $ArtifactsDir 'pshost-server.log' + + try { + Start-Transcript -Path $serverLogPath -Force | Out-Null + Import-Module AwakeCoding.PSRemoting -ErrorAction Stop + $server = Start-PSHostServer -TransportType TCP -Port $Port + $process = Get-Process -Id $PID + + [pscustomobject]@{ + Transport = 'TCP' + HostName = '127.0.0.1' + Port = $Port + ProcessId = $PID + SessionId = $process.SessionId + UserName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + State = [string] $server.State + StartedAt = (Get-Date).ToString('o') + } | ConvertTo-Json -Depth 4 | Set-Content -Path $EndpointPath -Encoding utf8NoBOM + + while ($true) { + Start-Sleep -Seconds 5 + $current = Get-PSHostServer -Port $Port -ErrorAction SilentlyContinue + if ($null -eq $current -or [string] $current.State -ne 'Running') { + throw "PSHostServer on port $Port is no longer running." + } + } + } + catch { + [pscustomobject]@{ + Error = $_.Exception.Message + ProcessId = $PID + FailedAt = (Get-Date).ToString('o') + } | ConvertTo-Json -Depth 4 | Set-Content -Path $EndpointPath -Encoding utf8NoBOM + throw + } + finally { + Stop-Transcript -ErrorAction SilentlyContinue | Out-Null + } +} + +if ($Wait) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + if (Test-Path $EndpointPath) { + $endpoint = Get-Content -Path $EndpointPath -Raw | ConvertFrom-Json + if ($endpoint.PSObject.Properties.Name -contains 'Error') { + throw "Interactive PSHostServer failed: $($endpoint.Error)" + } + + $endpoint | ConvertTo-Json -Compress + return + } + + Start-Sleep -Seconds 2 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for interactive PSHostServer endpoint marker: $EndpointPath" +} diff --git a/testing/release-smoke/Start-LocalRdpClient.ps1 b/testing/release-smoke/Start-LocalRdpClient.ps1 new file mode 100644 index 000000000..9f91d1731 --- /dev/null +++ b/testing/release-smoke/Start-LocalRdpClient.ps1 @@ -0,0 +1,131 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $UserName, + + [Parameter(Mandatory)] + [string] $Password, + + [string] $HostName = '127.0.0.1', + + [int] $Port = 3389, + + [string] $ArtifactsDir = (Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-smoke'), + + [string] $SecretsDir = (Join-Path $env:RUNNER_TEMP 'unigetui-release-smoke-secrets'), + + [string] $StatePath = (Join-Path $ArtifactsDir 'rdp-client-state.json') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-LoggedCommand { + param( + [Parameter(Mandatory)] + [string] $LogPath, + + [Parameter(Mandatory)] + [scriptblock] $ScriptBlock + ) + + New-Item -Path (Split-Path -Path $LogPath -Parent) -ItemType Directory -Force | Out-Null + & $ScriptBlock *>&1 | Tee-Object -FilePath $LogPath + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE. See $LogPath." + } +} + +function Ensure-Cargo { + if (Get-Command cargo -ErrorAction SilentlyContinue) { + return + } + + $rustupPath = Join-Path $env:RUNNER_TEMP 'rustup-init.exe' + Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile $rustupPath + & $rustupPath -y --default-toolchain stable --profile minimal + $env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH" + + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + throw 'cargo was not found after installing Rust with rustup.' + } +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +New-Item -Path $SecretsDir -ItemType Directory -Force | Out-Null + +Ensure-Cargo + +$ironRdpRoot = Join-Path $env:RUNNER_TEMP 'IronRDP' +$buildLogPath = Join-Path $ArtifactsDir 'ironrdp-build.log' + +if (-not (Test-Path $ironRdpRoot)) { + Invoke-LoggedCommand -LogPath (Join-Path $ArtifactsDir 'ironrdp-clone.log') -ScriptBlock { + git clone --depth 1 https://github.com/Devolutions/IronRDP.git $ironRdpRoot + } +} + +Push-Location $ironRdpRoot +try { + Invoke-LoggedCommand -LogPath $buildLogPath -ScriptBlock { + cargo build --release -p ironrdp-client + } +} +finally { + Pop-Location +} + +$clientPath = Join-Path $ironRdpRoot 'target\release\ironrdp-client.exe' +if (-not (Test-Path $clientPath)) { + throw "IronRDP client was not produced at $clientPath." +} + +$rdpFile = Join-Path $SecretsDir 'localhost.rdp' +@( + "full address:s:$HostName" + "server port:i:$Port" + "username:s:$UserName" + "ClearTextPassword:s:$Password" + 'enablecredsspsupport:i:0' + 'compression:i:0' + 'desktopwidth:i:800' + 'desktopheight:i:600' + 'desktopscalefactor:i:100' + 'audiomode:i:2' + 'redirectclipboard:i:0' +) | Set-Content -Path $rdpFile -Encoding ascii + +$stdoutPath = Join-Path $ArtifactsDir 'ironrdp-client.stdout.log' +$stderrPath = Join-Path $ArtifactsDir 'ironrdp-client.stderr.log' +$clientLogPath = Join-Path $ArtifactsDir 'ironrdp-client.log' +$arguments = @( + '--rdp-file', $rdpFile, + '--autologon', + '--no-credssp', + '--no-server-pointer', + '--compression-enabled=false', + '--color-depth', '16', + '--prevent-session-lock', '1', + '--log-file', $clientLogPath +) + +$process = Start-Process -FilePath $clientPath -ArgumentList $arguments -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -PassThru +Start-Sleep -Seconds 20 + +if ($process.HasExited) { + throw "IronRDP client exited early with code $($process.ExitCode). See $stdoutPath and $stderrPath." +} + +[pscustomobject]@{ + ProcessId = $process.Id + ClientPath = $clientPath + ClientLogPath = $clientLogPath + StandardOutputPath = $stdoutPath + StandardErrorPath = $stderrPath +} | ConvertTo-Json -Depth 4 | Set-Content -Path $StatePath -Encoding utf8NoBOM + +[pscustomobject]@{ + ProcessId = $process.Id + ClientPath = $clientPath + StatePath = $StatePath +} | ConvertTo-Json -Compress