From 441010377e07e1b72722d9448e5f023415538021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 13:52:24 -0400 Subject: [PATCH 01/10] Add release smoke test workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-smoke-test.yml | 72 +++++ testing/release-smoke/Enable-LocalRdp.ps1 | 172 ++++++++++ .../Install-AndLaunchRelease.ps1 | 293 ++++++++++++++++++ .../Invoke-InteractiveCommand.ps1 | 86 +++++ .../release-smoke/Invoke-ReleaseSmokeTest.ps1 | 159 ++++++++++ .../Start-InteractivePSHostServer.ps1 | 150 +++++++++ .../release-smoke/Start-LocalRdpClient.ps1 | 126 ++++++++ 7 files changed, 1058 insertions(+) create mode 100644 .github/workflows/release-smoke-test.yml create mode 100644 testing/release-smoke/Enable-LocalRdp.ps1 create mode 100644 testing/release-smoke/Install-AndLaunchRelease.ps1 create mode 100644 testing/release-smoke/Invoke-InteractiveCommand.ps1 create mode 100644 testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 create mode 100644 testing/release-smoke/Start-InteractivePSHostServer.ps1 create mode 100644 testing/release-smoke/Start-LocalRdpClient.ps1 diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml new file mode 100644 index 000000000..b2b8c5aa9 --- /dev/null +++ b/.github/workflows/release-smoke-test.yml @@ -0,0 +1,72 @@ +name: Release Smoke Test + +on: + 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: ${{ inputs['release-repository'] }} + RELEASE_TAG: ${{ inputs['release-tag'] }} + INSTALLER_ASSET_NAME: ${{ inputs['installer-asset-name'] }} + MAX_STAGE: ${{ inputs['max-stage'] }} + 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..84f695ea9 --- /dev/null +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -0,0 +1,293 @@ +[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 { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + 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; + } + + [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); +} +'@ + } +} + +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 = [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, $bounds.Size) + $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 { + $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, [System.Drawing.Size]::new($width, $height)) + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +$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..326464f37 --- /dev/null +++ b/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 @@ -0,0 +1,159 @@ +[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::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..9313b034e --- /dev/null +++ b/testing/release-smoke/Start-LocalRdpClient.ps1 @@ -0,0 +1,126 @@ +[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' + 'desktopwidth:i:1280' + 'desktopheight:i:720' + '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', + '--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 From 2902a1ca330fcbc8a36fd9f97e80e7e4d4b6446d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 13:58:40 -0400 Subject: [PATCH 02/10] Enable release smoke PR runs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-smoke-test.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml index b2b8c5aa9..23b8028f0 100644 --- a/.github/workflows/release-smoke-test.yml +++ b/.github/workflows/release-smoke-test.yml @@ -1,6 +1,11 @@ name: Release Smoke Test on: + pull_request: + branches: [ "main" ] + paths: + - '.github/workflows/release-smoke-test.yml' + - 'testing/release-smoke/**' workflow_dispatch: inputs: release-repository: @@ -43,10 +48,10 @@ jobs: shell: pwsh env: GITHUB_TOKEN: ${{ github.token }} - RELEASE_REPOSITORY: ${{ inputs['release-repository'] }} - RELEASE_TAG: ${{ inputs['release-tag'] }} - INSTALLER_ASSET_NAME: ${{ inputs['installer-asset-name'] }} - MAX_STAGE: ${{ inputs['max-stage'] }} + 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 ` From a361477bfa57325de242f77cc8654405cb20d16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 14:09:23 -0400 Subject: [PATCH 03/10] Harden IronRDP smoke connection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testing/release-smoke/Start-LocalRdpClient.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testing/release-smoke/Start-LocalRdpClient.ps1 b/testing/release-smoke/Start-LocalRdpClient.ps1 index 9313b034e..e50bb668d 100644 --- a/testing/release-smoke/Start-LocalRdpClient.ps1 +++ b/testing/release-smoke/Start-LocalRdpClient.ps1 @@ -87,6 +87,7 @@ $rdpFile = Join-Path $SecretsDir 'localhost.rdp' "username:s:$UserName" "ClearTextPassword:s:$Password" 'enablecredsspsupport:i:0' + 'compression:i:0' 'desktopwidth:i:1280' 'desktopheight:i:720' 'audiomode:i:2' @@ -100,6 +101,9 @@ $arguments = @( '--rdp-file', $rdpFile, '--autologon', '--no-credssp', + '--no-server-pointer', + '--compression-enabled=false', + '--color-depth', '16', '--prevent-session-lock', '1', '--log-file', $clientLogPath ) From ea6cb63f4b0831ab5b142657f7e2f69f79a4d9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 14:24:12 -0400 Subject: [PATCH 04/10] Start interactive PSHost task explicitly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 b/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 index 326464f37..e40629a75 100644 --- a/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 +++ b/testing/release-smoke/Invoke-ReleaseSmokeTest.ps1 @@ -123,6 +123,12 @@ try { } 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 ` From ef9b4b0e9ad752276bb5d5e61bd4c51c40aed553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 15:27:48 -0400 Subject: [PATCH 05/10] Use 1080p RDP smoke desktop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testing/release-smoke/Start-LocalRdpClient.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/release-smoke/Start-LocalRdpClient.ps1 b/testing/release-smoke/Start-LocalRdpClient.ps1 index e50bb668d..a94ad7428 100644 --- a/testing/release-smoke/Start-LocalRdpClient.ps1 +++ b/testing/release-smoke/Start-LocalRdpClient.ps1 @@ -88,8 +88,8 @@ $rdpFile = Join-Path $SecretsDir 'localhost.rdp' "ClearTextPassword:s:$Password" 'enablecredsspsupport:i:0' 'compression:i:0' - 'desktopwidth:i:1280' - 'desktopheight:i:720' + 'desktopwidth:i:1920' + 'desktopheight:i:1080' 'audiomode:i:2' 'redirectclipboard:i:0' ) | Set-Content -Path $rdpFile -Encoding ascii From dd3c2fb71ba0d92fbb74531aad72fcc10abd7c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 15:39:55 -0400 Subject: [PATCH 06/10] Use GDI fallback for smoke screenshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Install-AndLaunchRelease.ps1 | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/testing/release-smoke/Install-AndLaunchRelease.ps1 b/testing/release-smoke/Install-AndLaunchRelease.ps1 index 84f695ea9..e9e4abf8d 100644 --- a/testing/release-smoke/Install-AndLaunchRelease.ps1 +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -77,6 +77,18 @@ public static class ReleaseSmokeNativeMethods [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); } '@ } @@ -122,7 +134,28 @@ function Save-DesktopScreenshot { $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) try { - $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size) + try { + $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size) + } + catch { + $sourceDc = [ReleaseSmokeNativeMethods]::GetDC([IntPtr]::Zero) + if ($sourceDc -eq [IntPtr]::Zero) { + throw + } + + $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 { @@ -163,7 +196,29 @@ function Save-WindowScreenshot { $bitmap = [System.Drawing.Bitmap]::new($width, $height) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) try { - $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, [System.Drawing.Size]::new($width, $height)) + $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 { From aac30c6f524dffc779a3495a7ee4adfea3c6fd59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 15:51:54 -0400 Subject: [PATCH 07/10] Request full scale 1080p smoke screenshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testing/release-smoke/Install-AndLaunchRelease.ps1 | 11 ++++++++--- testing/release-smoke/Start-LocalRdpClient.ps1 | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/testing/release-smoke/Install-AndLaunchRelease.ps1 b/testing/release-smoke/Install-AndLaunchRelease.ps1 index e9e4abf8d..01a5399ab 100644 --- a/testing/release-smoke/Install-AndLaunchRelease.ps1 +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -50,9 +50,6 @@ function Download-ReleaseAsset { } function Initialize-ScreenshotSupport { - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing - if (-not ('ReleaseSmokeNativeMethods' -as [type])) { Add-Type -TypeDefinition @' using System; @@ -89,9 +86,17 @@ public static class ReleaseSmokeNativeMethods [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); } '@ } + + [ReleaseSmokeNativeMethods]::SetProcessDPIAware() | Out-Null + + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing } function Wait-UniGetUIWindow { diff --git a/testing/release-smoke/Start-LocalRdpClient.ps1 b/testing/release-smoke/Start-LocalRdpClient.ps1 index a94ad7428..308ea3ca6 100644 --- a/testing/release-smoke/Start-LocalRdpClient.ps1 +++ b/testing/release-smoke/Start-LocalRdpClient.ps1 @@ -90,6 +90,7 @@ $rdpFile = Join-Path $SecretsDir 'localhost.rdp' 'compression:i:0' 'desktopwidth:i:1920' 'desktopheight:i:1080' + 'desktopscalefactor:i:100' 'audiomode:i:2' 'redirectclipboard:i:0' ) | Set-Content -Path $rdpFile -Encoding ascii From 8e08930deb9be5c7d9352ab4dbeadb07a6924785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 16:04:58 -0400 Subject: [PATCH 08/10] Capture physical desktop pixels in smoke screenshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Install-AndLaunchRelease.ps1 | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/testing/release-smoke/Install-AndLaunchRelease.ps1 b/testing/release-smoke/Install-AndLaunchRelease.ps1 index 01a5399ab..de62c9462 100644 --- a/testing/release-smoke/Install-AndLaunchRelease.ps1 +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -89,6 +89,9 @@ public static class ReleaseSmokeNativeMethods [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); + + [DllImport("gdi32.dll")] + public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); } '@ } @@ -99,6 +102,44 @@ public static class ReleaseSmokeNativeMethods Add-Type -AssemblyName System.Drawing } +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 Wait-UniGetUIWindow { param( [Parameter(Mandatory)] @@ -135,31 +176,26 @@ function Save-DesktopScreenshot { $directory = Split-Path -Path $Path -Parent New-Item -Path $directory -ItemType Directory -Force | Out-Null - $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bounds = Get-PhysicalDesktopBounds $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) try { - try { - $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size) + $sourceDc = [ReleaseSmokeNativeMethods]::GetDC([IntPtr]::Zero) + if ($sourceDc -eq [IntPtr]::Zero) { + throw 'GetDC failed while capturing the desktop.' } - catch { - $sourceDc = [ReleaseSmokeNativeMethods]::GetDC([IntPtr]::Zero) - if ($sourceDc -eq [IntPtr]::Zero) { - throw - } - $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 + $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) } From e7a5ffc77b9aa088ac1aba620647b858e459fc94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 16:28:46 -0400 Subject: [PATCH 09/10] Enforce 1080p interactive smoke display Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Install-AndLaunchRelease.ps1 | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/testing/release-smoke/Install-AndLaunchRelease.ps1 b/testing/release-smoke/Install-AndLaunchRelease.ps1 index de62c9462..79ed033f3 100644 --- a/testing/release-smoke/Install-AndLaunchRelease.ps1 +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -66,6 +66,43 @@ public static class ReleaseSmokeNativeMethods 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); @@ -92,6 +129,12 @@ public static class ReleaseSmokeNativeMethods [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); } '@ } @@ -102,6 +145,35 @@ public static class ReleaseSmokeNativeMethods 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 @@ -140,6 +212,22 @@ function Get-PhysicalDesktopBounds { } } +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)] @@ -269,6 +357,24 @@ function Save-WindowScreenshot { } New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null +$targetDesktopWidth = 1920 +$targetDesktopHeight = 1080 +$displayBefore = Get-DisplayMetrics +Set-InteractiveDisplayResolution -Width $targetDesktopWidth -Height $targetDesktopHeight +Start-Sleep -Seconds 3 +$displayAfter = Get-DisplayMetrics + +if ($displayAfter.PhysicalWidth -ne $targetDesktopWidth -or $displayAfter.PhysicalHeight -ne $targetDesktopHeight) { + throw "RDP desktop is $($displayAfter.PhysicalWidth)x$($displayAfter.PhysicalHeight), expected ${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 From 38374fc83543ee3638dfa6e710062be07f0002f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 14 May 2026 16:38:49 -0400 Subject: [PATCH 10/10] Use small RDP smoke desktop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../release-smoke/Install-AndLaunchRelease.ps1 | 15 ++++++++++----- testing/release-smoke/Start-LocalRdpClient.ps1 | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/testing/release-smoke/Install-AndLaunchRelease.ps1 b/testing/release-smoke/Install-AndLaunchRelease.ps1 index 79ed033f3..8280b527c 100644 --- a/testing/release-smoke/Install-AndLaunchRelease.ps1 +++ b/testing/release-smoke/Install-AndLaunchRelease.ps1 @@ -357,15 +357,20 @@ function Save-WindowScreenshot { } New-Item -Path $ArtifactsDir -ItemType Directory -Force | Out-Null -$targetDesktopWidth = 1920 -$targetDesktopHeight = 1080 +$targetDesktopWidth = 800 +$targetDesktopHeight = 600 $displayBefore = Get-DisplayMetrics -Set-InteractiveDisplayResolution -Width $targetDesktopWidth -Height $targetDesktopHeight -Start-Sleep -Seconds 3 +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) { - throw "RDP desktop is $($displayAfter.PhysicalWidth)x$($displayAfter.PhysicalHeight), expected ${targetDesktopWidth}x${targetDesktopHeight}." + Write-Warning "RDP desktop is $($displayAfter.PhysicalWidth)x$($displayAfter.PhysicalHeight), requested ${targetDesktopWidth}x${targetDesktopHeight}." } [pscustomobject]@{ diff --git a/testing/release-smoke/Start-LocalRdpClient.ps1 b/testing/release-smoke/Start-LocalRdpClient.ps1 index 308ea3ca6..9f91d1731 100644 --- a/testing/release-smoke/Start-LocalRdpClient.ps1 +++ b/testing/release-smoke/Start-LocalRdpClient.ps1 @@ -88,8 +88,8 @@ $rdpFile = Join-Path $SecretsDir 'localhost.rdp' "ClearTextPassword:s:$Password" 'enablecredsspsupport:i:0' 'compression:i:0' - 'desktopwidth:i:1920' - 'desktopheight:i:1080' + 'desktopwidth:i:800' + 'desktopheight:i:600' 'desktopscalefactor:i:100' 'audiomode:i:2' 'redirectclipboard:i:0'