diff --git a/.github/workflows/windows-msi-gui-test.yml b/.github/workflows/windows-msi-gui-test.yml new file mode 100644 index 0000000000..5ca5328c1a --- /dev/null +++ b/.github/workflows/windows-msi-gui-test.yml @@ -0,0 +1,93 @@ +name: windows-msi-gui-test +run-name: "Windows GUI: ${{ inputs.scenario }} ${{ inputs.label }}" + +on: + workflow_dispatch: + inputs: + scenario: + description: "Scenario to run (all: every scenario except the known issue update-desktop; everything: all of them)" + required: true + default: all + type: choice + options: [all, everything, uninstall-desktop, update-desktop, update-same-build, default-folder, profile-folder, appdata-folder] + label: + description: "Free text shown in the run name" + required: false + default: "" + type: string + new_msi_run_id: + description: "Id of a workflow run that uploaded the MSI under test as an artifact (empty: use new_msi_url)" + required: false + default: "" + type: string + new_msi_artifact: + description: "Name of the MSI artifact in that run" + required: false + default: "simplex-desktop-windows-x86_64-msi" + type: string + new_msi_url: + description: "URL of the new MSI when new_msi_run_id is empty" + required: false + default: "https://github.com/simplex-chat/simplex-chat/releases/download/v7.0.3/simplex-desktop-windows-x86_64.msi" + type: string + old_msi_url: + description: "URL of the old MSI (installed before updating)" + required: false + default: "https://github.com/simplex-chat/simplex-chat/releases/download/v7.0.2/simplex-desktop-windows-x86_64.msi" + type: string + patch_with_ref: + description: "Branch whose scripts/desktop/disable-msi-install-dir-wipe-windows.ps1 is applied to the new MSI (empty: no patch)" + required: false + default: "" + type: string + +permissions: + contents: read + actions: read + +jobs: + windows-msi-gui-test: + runs-on: windows-latest + timeout-minutes: 60 + steps: + - name: Checkout test scripts + uses: actions/checkout@v4 + with: + sparse-checkout: apps/multiplatform/scripts/windows-msi + + - name: Checkout MSI patch script + if: inputs.patch_with_ref != '' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.patch_with_ref }} + path: patchsrc + sparse-checkout: scripts/desktop + + - name: Download new MSI from build run + if: inputs.new_msi_run_id != '' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.new_msi_artifact }} + run-id: ${{ inputs.new_msi_run_id }} + github-token: ${{ github.token }} + path: C:\msitest\artifact + + - name: Run scenarios + shell: pwsh + env: + SCENARIO: ${{ inputs.scenario }} + NEW_MSI_URL: ${{ inputs.new_msi_url }} + NEW_MSI_DIR: ${{ inputs.new_msi_run_id != '' && 'C:\msitest\artifact' || '' }} + OLD_MSI_URL: ${{ inputs.old_msi_url }} + PATCH_SCRIPT: ${{ inputs.patch_with_ref != '' && 'patchsrc\scripts\desktop\disable-msi-install-dir-wipe-windows.ps1' || '' }} + run: | + New-Item -ItemType Directory -Force C:\msitest | Out-Null + & apps/multiplatform/scripts/windows-msi/msi-scenarios.ps1 -Scenario $env:SCENARIO -NewMsiUrl $env:NEW_MSI_URL -NewMsiDir $env:NEW_MSI_DIR -OldMsiUrl $env:OLD_MSI_URL -PatchScript $env:PATCH_SCRIPT + + - name: Upload evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-gui-evidence + path: C:\msitest\evidence + retention-days: 30 diff --git a/apps/multiplatform/scripts/windows-msi/README.md b/apps/multiplatform/scripts/windows-msi/README.md new file mode 100644 index 0000000000..1373f3a312 --- /dev/null +++ b/apps/multiplatform/scripts/windows-msi/README.md @@ -0,0 +1,62 @@ +# Windows installer GUI tests + +These tests install, update, start and uninstall the desktop app through the real MSI installer +UI on a GitHub-hosted Windows runner. `msi-scenarios.ps1` drives the installer dialogs and +Explorer through UI Automation (`wingui.psm1`), takes screenshots, keeps the installer logs, and +asserts what is on disk after each step. There is no local Windows harness: the tests run only +in the `windows-msi-gui-test` workflow, which is started manually. + +## Running + +Start the workflow from the Actions tab or with `gh`: + +```sh +gh workflow run windows-msi-gui-test.yml -R /simplex-chat --ref \ + -f scenario=all -f label="what this run checks" \ + -f new_msi_run_id= +``` + +| Input | Meaning | +|---|---| +| `scenario` | One scenario, `all` (every scenario except known issues) or `everything`. | +| `label` | Free text shown in the run name. | +| `new_msi_run_id`, `new_msi_artifact` | Run id and artifact name of the MSI under test. | +| `new_msi_url` | URL of the MSI under test, used when `new_msi_run_id` is empty (default: the v7.0.3 release). | +| `old_msi_url` | MSI installed before the update in `update-desktop` (default: the v7.0.2 release). | +| `patch_with_ref` | Branch whose `scripts/desktop/disable-msi-install-dir-wipe-windows.ps1` is applied to the MSI under test before the scenarios. Checks an MSI change in about 10 minutes without a Windows build. | + +The run uploads the artifact `windows-gui-evidence`: + +- `checks.txt`: one line per check, `name = actual (expected X) PASS|FAIL`, plus informational lines; +- `captions.tsv` and numbered PNG screenshots (1024x768); +- `logs/`: verbose `msiexec` logs (UTF-16) for every install and uninstall. + +The job fails if any check fails or a dialog does not appear in time. A missing dialog is +recorded as an `Unexpected state` screenshot. + +`gh run view --log` can return an empty log; read it with +`gh api repos//simplex-chat/actions/jobs//logs`. + +## Scenarios + +| Scenario | Steps | Expected | +|---|---|---| +| `uninstall-desktop` | Put user files on the Desktop, install into `Desktop\` (the installer warns that the folder exists), uninstall | User files and Desktop remain; `SimpleX.exe`, `app`, `runtime` are removed | +| `update-same-build` | Install into `Desktop\`, update through the installer UI to a copy of the same MSI with a higher version | User files remain; old files removed; the updated app starts | +| `default-folder` | Install into `C:\Program Files\SimpleX\`, start the app, uninstall | The app starts; the folder is removed | +| `profile-folder` | Install into the user profile folder, start the app, add user files there, uninstall | User files and every file in `%AppData%\SimpleX` remain | +| `appdata-folder` | The same with `%AppData%\` as the install folder | As above | +| `update-desktop` (known issue) | Install the old MSI into `Desktop\`, update to the MSI under test | User files remain. Fails while the old MSI deletes its install folder on removal | + +The update scenarios update the way the in-app updater does: `msiexec /i` with the full UI and +the default folder. The higher-version copy for `update-same-build` is made by the script: it +changes the product version, product code, package code, `Upgrade` rows and the versioned +registry key of the MSI under test. + +## Notes + +- Before a fix, `profile-folder` and `appdata-folder` delete files of the runner's user profile; + they run last. +- The runner user is an administrator, so the installer does not show a UAC prompt. +- Windows Installer keeps the MSI open after a COM call until the COM objects are released; + `Close-MsiHandles` forces that before the file is opened again. diff --git a/apps/multiplatform/scripts/windows-msi/msi-scenarios.ps1 b/apps/multiplatform/scripts/windows-msi/msi-scenarios.ps1 new file mode 100644 index 0000000000..4930533770 --- /dev/null +++ b/apps/multiplatform/scripts/windows-msi/msi-scenarios.ps1 @@ -0,0 +1,347 @@ +param( + [string]$Scenario = 'all', + [string]$NewMsiUrl = '', + [string]$NewMsiDir = '', + [string]$OldMsiUrl = '', + [string]$PatchScript = '', + [string]$Work = 'C:\msitest' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Import-Module (Join-Path $PSScriptRoot 'wingui.psm1') + +$Evidence = Join-Path $Work 'evidence' +$Logs = Join-Path $Evidence 'logs' +Initialize-Evidence $Evidence +New-Item -ItemType Directory -Force $Logs | Out-Null + +$Desktop = [Environment]::GetFolderPath('Desktop') +if (-not $Desktop -or -not $Desktop.EndsWith('\Desktop')) { throw "Unexpected Desktop path '$Desktop'" } +$UserProfile = $env:USERPROFILE +if (-not $UserProfile -or -not (Test-Path (Join-Path $UserProfile 'AppData\Roaming'))) { throw "Unexpected user profile '$UserProfile'" } +$DataDir = Join-Path $env:APPDATA 'SimpleX' +$ProgramFilesDir = Join-Path $env:ProgramFiles 'SimpleX' +$Setup = 'SimpleX Setup' +$UserFiles = @('My notes.txt', 'Tax return 2025.txt', 'Holiday photos\beach.txt') +$SeededNames = @('My notes.txt', 'Tax return 2025.txt', 'Holiday photos') +$InstalledNames = @('SimpleX.exe', 'app', 'runtime') +$FolderUserFiles = @('My profile notes.txt', '.my-settings\settings.txt') +$FolderUserNames = @('My profile notes.txt', '.my-settings') +$script:Failures = @() + +function Write-Check([string]$Name, $Value) { + Write-Host "CHECK $Name = $Value" + Add-Content (Join-Path $Evidence 'checks.txt') "$Name = $Value" +} + +function Assert-Check([string]$Name, $Actual, $Expected) { + $verdict = if ("$Actual" -eq "$Expected") { 'PASS' } else { 'FAIL' } + if ($verdict -eq 'FAIL') { $script:Failures += $Name } + Write-Check $Name "$Actual (expected $Expected) $verdict" +} + +function Invoke-Com($Object, [string]$Method, [object[]]$Arguments) { + $Object.GetType().InvokeMember($Method, [Reflection.BindingFlags]::InvokeMethod, $null, $Object, $Arguments) +} + +function Get-ComProperty($Object, [string]$Name, [object[]]$Arguments) { + $Object.GetType().InvokeMember($Name, [Reflection.BindingFlags]::GetProperty, $null, $Object, $Arguments) +} + +function Open-MsiDatabase([string]$Msi, [int]$Mode) { + Invoke-Com (New-Object -ComObject WindowsInstaller.Installer) 'OpenDatabase' @($Msi, $Mode) +} + +function Invoke-MsiSql($Database, [string]$Sql) { + $view = Invoke-Com $Database 'OpenView' @($Sql) + Invoke-Com $view 'Execute' @() | Out-Null + $values = @() + if ($Sql.StartsWith('SELECT')) { + while ($record = Invoke-Com $view 'Fetch' @()) { + $values += Get-ComProperty $record 'StringData' @(1) + } + } + Invoke-Com $view 'Close' @() | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null + $values +} + +function Read-MsiColumn([string]$Msi, [string]$Sql) { + $db = Open-MsiDatabase $Msi 0 + $values = Invoke-MsiSql $db $Sql + Close-MsiHandles + $values +} + +function Close-MsiHandles { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} + +function New-LaterVersionMsi([string]$Msi, [string]$Path) { + Copy-Item $Msi $Path + Set-ItemProperty $Path -Name IsReadOnly -Value $false + $db = Open-MsiDatabase $Path 1 + $version = Invoke-MsiSql $db 'SELECT `Value` FROM `Property` WHERE `Property`=''ProductVersion''' + $parts = $version.Split('.') + $later = "$($parts[0]).$($parts[1]).$([int]$parts[2] + 1)" + $upgradeCode = Invoke-MsiSql $db 'SELECT `Value` FROM `Property` WHERE `Property`=''UpgradeCode''' + Invoke-MsiSql $db "UPDATE ``Property`` SET ``Value``='$later' WHERE ``Property``='ProductVersion'" | Out-Null + Invoke-MsiSql $db "UPDATE ``Property`` SET ``Value``='{$([guid]::NewGuid().ToString().ToUpper())}' WHERE ``Property``='ProductCode'" | Out-Null + Invoke-MsiSql $db 'DELETE FROM `Upgrade`' | Out-Null + Invoke-MsiSql $db "INSERT INTO ``Upgrade`` (``UpgradeCode``, ``VersionMax``, ``Attributes``, ``ActionProperty``) VALUES ('$upgradeCode', '$later', 257, 'JP_UPGRADABLE_FOUND')" | Out-Null + Invoke-MsiSql $db "INSERT INTO ``Upgrade`` (``UpgradeCode``, ``VersionMin``, ``Attributes``, ``ActionProperty``) VALUES ('$upgradeCode', '$later', 1, 'JP_DOWNGRADABLE_FOUND')" | Out-Null + foreach ($table in 'Registry', 'RegLocator') { + foreach ($key in @(Invoke-MsiSql $db "SELECT ``Key`` FROM ``$table``")) { + if ($key.EndsWith("\$version")) { + $laterKey = $key.Substring(0, $key.Length - $version.Length) + $later + Invoke-MsiSql $db "UPDATE ``$table`` SET ``Key``='$laterKey' WHERE ``Key``='$key'" | Out-Null + } + } + } + $summary = Get-ComProperty $db 'SummaryInformation' @(20) + $summary.GetType().InvokeMember('Property', [Reflection.BindingFlags]::SetProperty, $null, $summary, @(9, "{$([guid]::NewGuid().ToString().ToUpper())}")) | Out-Null + Invoke-Com $summary 'Persist' @() | Out-Null + Invoke-Com $db 'Commit' @() | Out-Null + $summary = $null + $db = $null + Close-MsiHandles + $Path +} + +function Get-Msi([string]$Url, [string]$Dir, [string]$Name) { + $path = Join-Path $Work "$Name.msi" + if ($Dir) { + $file = Get-ChildItem $Dir -Recurse -Filter *.msi | Select-Object -First 1 + if (-not $file) { throw "No .msi in $Dir" } + Copy-Item $file.FullName $path + } else { + Invoke-WebRequest $Url -OutFile $path + } + $path +} + +function Describe-Msi([string]$Msi, [string]$Name) { + $version = Read-MsiColumn $Msi 'SELECT `Value` FROM `Property` WHERE `Property`=''ProductVersion''' + $code = Read-MsiColumn $Msi 'SELECT `Value` FROM `Property` WHERE `Property`=''ProductCode''' + $rows = try { @(Read-MsiColumn $Msi 'SELECT `WixRemoveFolderEx` FROM `WixRemoveFolderEx`').Count } catch { 'table absent' } + Write-Check "$Name MSI version" $version + Write-Check "$Name MSI WixRemoveFolderEx rows" $rows + Write-Check "$Name MSI sha256" (Get-FileHash $Msi -Algorithm SHA256).Hash + $code +} + +function Test-UserFiles([string]$Label) { + foreach ($file in $UserFiles) { + Assert-Check "$Label - Desktop\$file exists" (Test-Path (Join-Path $Desktop $file)) $true + } +} + +function Reset-State { + Get-Process -Name SimpleX -ErrorAction SilentlyContinue | Stop-Process -Force + foreach ($code in $script:ProductCodes) { + Start-Process msiexec.exe -ArgumentList @('/x', $code, '/qn') -Wait + } + $installFolders = @($Desktop, $UserProfile, $env:APPDATA) + $paths = @($SeededNames | ForEach-Object { Join-Path $Desktop $_ }) + foreach ($folder in $installFolders) { + $paths += @($InstalledNames + $FolderUserNames | ForEach-Object { Join-Path $folder $_ }) + } + foreach ($path in $paths) { + if (Test-Path $path) { Remove-Item -Recurse -Force $path } + } + New-Item -ItemType Directory -Force $Desktop | Out-Null +} + +function Add-FolderUserFiles([string]$Folder) { + Set-Content (Join-Path $Folder 'My profile notes.txt') 'A user file next to the install folder contents.' + New-Item -ItemType Directory -Force (Join-Path $Folder '.my-settings') | Out-Null + Set-Content (Join-Path $Folder '.my-settings\settings.txt') 'A user folder that sorts before AppData.' +} + +function Add-UserFiles { + Set-Content (Join-Path $Desktop 'My notes.txt') 'Personal notes that must survive uninstalling SimpleX.' + Set-Content (Join-Path $Desktop 'Tax return 2025.txt') 'A document unrelated to SimpleX.' + New-Item -ItemType Directory -Force (Join-Path $Desktop 'Holiday photos') | Out-Null + Set-Content (Join-Path $Desktop 'Holiday photos\beach.txt') 'A file in a user folder.' +} + +function Install-WithUi([string]$Msi, [string]$Folder, [string]$Tag, [string]$What) { + $log = Join-Path $Logs "$Tag-install.log" + $process = Start-Process msiexec.exe -ArgumentList @('/i', "`"$Msi`"", '/l*v', "`"$log`"") -PassThru + Invoke-Element (Wait-Button $Setup @('Next') 180) + Wait-Button $Setup @('Change...') | Out-Null + if ($Folder) { + Set-EditText $Setup $Folder | Out-Null + Start-Sleep -Milliseconds 500 + Save-Screenshot "$What - $Folder typed as the install folder" + } else { + Save-Screenshot "$What - default install folder" + } + $next = Wait-Button $Setup @('Next') + $next.SetFocus() + Start-Sleep -Milliseconds 300 + Invoke-Element $next + $button = Wait-Button $Setup @('Yes', 'Install') + if ($button.Current.Name -eq 'Yes') { + Save-Screenshot "$What - installer warns the folder already exists" + Invoke-Element $button + $button = Wait-Button $Setup @('Install') + } + Invoke-Element $button + $finish = Wait-Button $Setup @('Finish') 900 + Save-Screenshot "$What - installer finished" + Invoke-Element $finish + $process.WaitForExit() + Assert-Check "$Tag install exit code" $process.ExitCode 0 + $installDir = Select-String -Path $log -Pattern 'Property\(S\): INSTALLDIR = (.*)' | Select-Object -Last 1 + Write-Check "$Tag INSTALLDIR" $(if ($installDir) { $installDir.Matches[0].Groups[1].Value } else { '' }) +} + +function Uninstall-WithUi([string]$Msi, [string]$Tag, [string]$What) { + $log = Join-Path $Logs "$Tag-uninstall.log" + $process = Start-Process msiexec.exe -ArgumentList @('/x', "`"$Msi`"", '/l*v', "`"$log`"") -PassThru + $yes = Wait-Button 'Windows Installer' @('Yes') 180 + Save-Screenshot "$What - uninstall confirmation" + Invoke-Element $yes + $process.WaitForExit() + Assert-Check "$Tag uninstall exit code" $process.ExitCode 0 +} + +function Start-App([string]$Exe, [string]$Caption) { + $app = Start-Process $Exe -PassThru + Start-Sleep -Seconds 45 + Save-Screenshot $Caption + $running = -not $app.HasExited + Get-Process -Name SimpleX -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Sleep -Seconds 3 + $running +} + +function Test-UninstallDesktop { + Reset-State + Add-UserFiles + Show-Folder $Desktop 'Desktop with the user''s own files, before installing SimpleX' + Install-WithUi $script:NewMsi "$Desktop\" 'uninstall-desktop' 'Install into Desktop' + Show-Folder $Desktop 'Desktop after installing SimpleX into it' + Assert-Check 'uninstall-desktop - SimpleX.exe installed in Desktop' (Test-Path (Join-Path $Desktop 'SimpleX.exe')) $true + Uninstall-WithUi $script:NewMsi 'uninstall-desktop' 'Uninstall from Desktop' + Show-Folder $Desktop 'Desktop after uninstalling SimpleX' + Test-UserFiles 'uninstall-desktop after uninstall' + foreach ($name in $InstalledNames) { + Assert-Check "uninstall-desktop after uninstall - Desktop\$name exists" (Test-Path (Join-Path $Desktop $name)) $false + } +} + +function Test-Update([string]$Tag, [string]$FromMsi, [string]$ToMsi, [string]$FromWhat, [string]$ToWhat) { + if (-not $FromMsi -or -not $ToMsi) { throw "$Tag needs two MSIs" } + Reset-State + Add-UserFiles + Show-Folder $Desktop "Desktop with the user's own files, before installing $FromWhat" + Install-WithUi $FromMsi "$Desktop\" "$Tag-from" "Install $FromWhat into Desktop" + Show-Folder $Desktop "Desktop after installing $FromWhat into it" + Install-WithUi $ToMsi '' "$Tag-to" "Update to $ToWhat" + Show-Folder $Desktop "Desktop after updating to $ToWhat" + Test-UserFiles "$Tag after update" + foreach ($name in $InstalledNames) { + Assert-Check "$Tag after update - Desktop\$name exists" (Test-Path (Join-Path $Desktop $name)) $false + } + Assert-Check "$Tag - updated SimpleX.exe in Program Files" (Test-Path (Join-Path $ProgramFilesDir 'SimpleX.exe')) $true + Assert-Check "$Tag - updated app running after 45 s" (Start-App (Join-Path $ProgramFilesDir 'SimpleX.exe') "SimpleX started after updating to $ToWhat") $true +} + +function Test-DefaultFolder { + Reset-State + Add-UserFiles + Install-WithUi $script:NewMsi '' 'default-folder' 'Install into the default folder' + Show-Folder $ProgramFilesDir 'Program Files SimpleX folder after installing' + Assert-Check 'default-folder - app running after 45 s' (Start-App (Join-Path $ProgramFilesDir 'SimpleX.exe') 'SimpleX started from the default install folder') $true + Uninstall-WithUi $script:NewMsi 'default-folder' 'Uninstall from the default folder' + Show-Folder $env:ProgramFiles 'Program Files after uninstalling SimpleX' + Assert-Check 'default-folder after uninstall - Program Files SimpleX folder exists' (Test-Path $ProgramFilesDir) $false + Test-UserFiles 'default-folder after uninstall' +} + +function Test-DataFolder([string]$Tag, [string]$Folder, [string]$What) { + Reset-State + Install-WithUi $script:NewMsi "$Folder\" $Tag "Install into $What" + Assert-Check "$Tag - app running after 45 s" (Start-App (Join-Path $Folder 'SimpleX.exe') "SimpleX started from $What") $true + Add-FolderUserFiles $Folder + $dataFiles = @(Get-ChildItem -Recurse -File $DataDir | ForEach-Object { $_.FullName }) + Write-Check "$Tag - SimpleX data files before uninstall" ($dataFiles -join ', ') + Assert-Check "$Tag - chat database exists before uninstall" (Test-Path (Join-Path $DataDir 'simplex_v1_chat.db')) $true + Show-Folder $Folder "$What with SimpleX and the user's files, before uninstalling" + Show-Folder $DataDir 'SimpleX data folder (database) before uninstalling' + Uninstall-WithUi $script:NewMsi $Tag "Uninstall from $What" + Show-Folder $Folder "$What after uninstalling SimpleX" + Show-Folder $DataDir 'SimpleX data folder (database) after uninstalling' + foreach ($file in $FolderUserFiles) { + Assert-Check "$Tag after uninstall - $file in $What exists" (Test-Path (Join-Path $Folder $file)) $true + } + foreach ($file in $dataFiles) { + Assert-Check "$Tag after uninstall - $file exists" (Test-Path $file) $true + } + foreach ($name in $InstalledNames) { + Assert-Check "$Tag after uninstall - $name in $What exists" (Test-Path (Join-Path $Folder $name)) $false + } +} + +Write-Check 'session id' ([System.Diagnostics.Process]::GetCurrentProcess().SessionId) +Write-Check 'explorer running' ([bool](Get-Process explorer -ErrorAction SilentlyContinue)) +Write-Check 'screen' ([System.Windows.Forms.SystemInformation]::VirtualScreen) +Write-Check 'EnableLUA' (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System').EnableLUA +Write-Check 'Desktop' $Desktop +Write-Check 'user profile' $UserProfile +Save-Screenshot 'Runner screen at start' + +$script:NewMsi = Get-Msi $NewMsiUrl $NewMsiDir 'new' +Write-Check 'direct COM call WindowsInstaller.Installer.OpenDatabase' $(try { $null = (New-Object -ComObject WindowsInstaller.Installer).OpenDatabase($script:NewMsi, 0); 'works' } catch { "fails: $($_.Exception.Message)" }) +Close-MsiHandles +if ($PatchScript) { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Resolve-Path $PatchScript) $script:NewMsi + Assert-Check 'patch script exit code' $LASTEXITCODE 0 + Write-Check 'patched new MSI - ClearOlderVersionsInstallDirWipe action' ((Read-MsiColumn $script:NewMsi 'SELECT `Target` FROM `CustomAction` WHERE `Action`=''ClearOlderVersionsInstallDirWipe''') -join ' | ') + Write-Check 'patched new MSI - RemoveExistingProducts sequence' (Read-MsiColumn $script:NewMsi 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action`=''RemoveExistingProducts''') +} +$script:OldMsi = Get-Msi $OldMsiUrl '' 'old' +$script:ProductCodes = @((Describe-Msi $script:NewMsi 'new'), (Describe-Msi $script:OldMsi 'old')) +$script:LaterMsi = $null +try { + $script:LaterMsi = New-LaterVersionMsi $script:NewMsi (Join-Path $Work 'later.msi') + $script:ProductCodes += Describe-Msi $script:LaterMsi 'later' +} catch { + Write-Check 'later MSI error' $_.Exception.Message +} + +$scenarios = [ordered]@{ + 'uninstall-desktop' = { Test-UninstallDesktop } + 'update-desktop' = { Test-Update 'update-desktop' $script:OldMsi $script:NewMsi 'the old version' 'the new version' } + 'update-same-build' = { Test-Update 'update-same-build' $script:NewMsi $script:LaterMsi 'the new version' 'a later version of the same build' } + 'default-folder' = { Test-DefaultFolder } + 'profile-folder' = { Test-DataFolder 'profile-folder' $UserProfile 'the user profile folder' } + 'appdata-folder' = { Test-DataFolder 'appdata-folder' $env:APPDATA 'the AppData Roaming folder' } +} +$knownIssues = @('update-desktop') +$selected = switch ($Scenario) { + 'all' { @($scenarios.Keys | Where-Object { $_ -notin $knownIssues }) } + 'everything' { @($scenarios.Keys) } + default { @($Scenario) } +} +$errors = @() +foreach ($name in $selected) { + Write-Host "=== SCENARIO $name" + try { + & $scenarios[$name] + } catch { + Write-Host "SCENARIO $name ERROR: $($_.Exception.Message)" + Write-Check "$name error" $_.Exception.Message + $errors += $name + Get-Process -Name msiexec -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } +} +Reset-State +Write-Host "FAILED CHECKS: $($script:Failures.Count)" +$script:Failures | ForEach-Object { Write-Host " FAIL $_" } +if ($errors) { throw "Scenarios with unexpected state: $($errors -join ', ')" } +if ($script:Failures) { throw "$($script:Failures.Count) checks failed" } diff --git a/apps/multiplatform/scripts/windows-msi/wingui.psm1 b/apps/multiplatform/scripts/windows-msi/wingui.psm1 new file mode 100644 index 0000000000..3e13bc6e2d --- /dev/null +++ b/apps/multiplatform/scripts/windows-msi/wingui.psm1 @@ -0,0 +1,135 @@ +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Windows.Forms, System.Drawing + +$script:EvidenceDir = $null +$script:ShotIndex = 0 + +function Initialize-Evidence([string]$Dir) { + New-Item -ItemType Directory -Force $Dir | Out-Null + $script:EvidenceDir = $Dir + $script:ShotIndex = 0 + Set-Content (Join-Path $Dir 'captions.tsv') "file`tcaption" +} + +function Save-Screenshot([string]$Caption) { + $script:ShotIndex++ + $slug = ($Caption -replace '[^A-Za-z0-9]+', '-').Trim('-').ToLower() + $name = '{0:D2}-{1}.png' -f $script:ShotIndex, $slug + try { + $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bmp.Size) + $bmp.Save((Join-Path $script:EvidenceDir $name), [System.Drawing.Imaging.ImageFormat]::Png) + $g.Dispose() + $bmp.Dispose() + Add-Content (Join-Path $script:EvidenceDir 'captions.tsv') "$name`t$Caption" + Write-Host "SHOT $name : $Caption" + } catch { + Write-Host "SHOT FAILED $name : $Caption : $($_.Exception.Message)" + } +} + +function Get-TopWindow([string]$Title) { + $cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $Title) + [System.Windows.Automation.AutomationElement]::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond) +} + +function Find-InWindow([string]$Title, [string]$Name, $ControlType) { + $window = Get-TopWindow $Title + if (-not $window) { return $null } + $cond = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $Name)), + (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ControlType))) + $window.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond) +} + +function Wait-Window([string]$Title, [int]$TimeoutSec = 60) { + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while ((Get-Date) -lt $deadline) { + $window = Get-TopWindow $Title + if ($window) { return $window } + Start-Sleep -Milliseconds 300 + } + Save-Screenshot "Unexpected state: no window $Title" + throw "UNEXPECTED STATE: window '$Title' not found within $TimeoutSec s" +} + +function Wait-Button([string]$Title, [string[]]$Names, [int]$TimeoutSec = 60) { + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while ((Get-Date) -lt $deadline) { + foreach ($name in $Names) { + $button = Find-InWindow $Title $name ([System.Windows.Automation.ControlType]::Button) + if ($button -and $button.Current.IsEnabled) { return $button } + } + Start-Sleep -Milliseconds 300 + } + Save-Screenshot "Unexpected state: no button $($Names -join ' or ') in $Title" + throw "UNEXPECTED STATE: button '$($Names -join "' or '")' not found in '$Title' within $TimeoutSec s" +} + +function Invoke-Element($Element) { + $Element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() +} + +function Set-EditText([string]$Title, [string]$Text, [int]$TimeoutSec = 60) { + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $edit = $null + while (-not $edit -and (Get-Date) -lt $deadline) { + $window = Get-TopWindow $Title + if ($window) { + $cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::Edit) + $edit = $window.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond) + } + if (-not $edit) { Start-Sleep -Milliseconds 300 } + } + if (-not $edit) { + Save-Screenshot "Unexpected state: no edit box in $Title" + throw "UNEXPECTED STATE: no edit box in '$Title' within $TimeoutSec s" + } + $edit.SetFocus() + try { + $edit.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).SetValue($Text) + } catch { + Write-Host "ValuePattern failed ($($_.Exception.Message)), typing instead" + [System.Windows.Forms.SendKeys]::SendWait('^a') + [System.Windows.Forms.SendKeys]::SendWait($Text) + } + $edit +} + +function Close-ExplorerWindows { + foreach ($window in @((New-Object -ComObject Shell.Application).Windows())) { + try { $window.Quit() } catch {} + } + Start-Sleep -Seconds 1 +} + +function Show-Folder([string]$Path, [string]$Caption) { + $listing = if (Test-Path $Path) { (Get-ChildItem -Force $Path | ForEach-Object { $_.Name }) -join ', ' } else { '' } + Write-Host "LISTING $Path : $listing" + Close-ExplorerWindows + if (-not (Test-Path $Path)) { + $Caption = "$Caption (the folder itself no longer exists, its parent is shown)" + $Path = Split-Path $Path -Parent + } + $leaf = Split-Path $Path -Leaf + Start-Process explorer.exe -ArgumentList "`"$Path`"" + $window = $null + $deadline = (Get-Date).AddSeconds(20) + while (-not $window -and (Get-Date) -lt $deadline) { + $window = Get-TopWindow $leaf + if (-not $window) { Start-Sleep -Milliseconds 300 } + } + if ($window) { + try { + $window.GetCurrentPattern([System.Windows.Automation.WindowPattern]::Pattern).SetWindowVisualState([System.Windows.Automation.WindowVisualState]::Maximized) + } catch {} + Start-Sleep -Seconds 2 + } + Save-Screenshot $Caption + Close-ExplorerWindows + $unavailable = Find-InWindow 'Location is not available' 'OK' ([System.Windows.Automation.ControlType]::Button) + if ($unavailable) { Invoke-Element $unavailable } +} + +Export-ModuleMember -Function Initialize-Evidence, Save-Screenshot, Get-TopWindow, Find-InWindow, Wait-Window, Wait-Button, Invoke-Element, Set-EditText, Close-ExplorerWindows, Show-Folder