ui tests: Windows MSI installer GUI tests on a GitHub runner (install folder, update, uninstall)

This commit is contained in:
Narasimha-sc committed 2026-10-21 08:51:07 +00:00
1 parent 8927c18925
commit 5362e72faa
4 files changed
+637

No files matched your search

@@ -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
@@ -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 <owner>/simplex-chat --ref <branch with the workflow> \
-f scenario=all -f label="what this run checks" \
-f new_msi_run_id=<run id of a workflow that uploaded the MSI as an artifact>
```
| 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/<owner>/simplex-chat/actions/jobs/<job id>/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.
@@ -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 { '<not in log>' })
}
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" }
@@ -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 { '<folder does not exist>' }
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