mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 15:07:32 +00:00
ui tests: Windows MSI installer GUI tests on a GitHub runner (install folder, update, uninstall)
This commit is contained in:
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
|
||||
Reference in new issue
Block a user