From 213408b0c9523ae0bb6522a353ff550b34b22700 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 06:15:04 -0700 Subject: [PATCH 1/2] test: run install.ps1 behavior tests on Windows Builds stub release archives locally and serves them through an Invoke-WebRequest stand-in, then checks the default name, INSTALLED_NAME in either case, and reinstalling while the installed exe (or its moved-aside copy) is running, under Windows PowerShell 5.1 and PowerShell 7. --- .github/workflows/install-scripts.yml | 19 +++ tests/install_ps1_test.ps1 | 171 ++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 tests/install_ps1_test.ps1 diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml index 889d13c..3dbdc80 100644 --- a/.github/workflows/install-scripts.yml +++ b/.github/workflows/install-scripts.yml @@ -8,12 +8,14 @@ on: - install.sh - install.ps1 - tests/install_sh_sudo_test.sh + - tests/install_ps1_test.ps1 - .github/workflows/install-scripts.yml pull_request: paths: - install.sh - install.ps1 - tests/install_sh_sudo_test.sh + - tests/install_ps1_test.ps1 - .github/workflows/install-scripts.yml permissions: @@ -34,6 +36,23 @@ jobs: - name: installer behavior tests run: sh tests/install_sh_sudo_test.sh + windows: + name: install.ps1 behavior (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: "go.mod" + cache: false + - name: installer behavior tests (Windows PowerShell 5.1) + shell: powershell + run: ./tests/install_ps1_test.ps1 + - name: installer behavior tests (PowerShell 7) + if: ${{ !cancelled() }} + shell: pwsh + run: ./tests/install_ps1_test.ps1 + mirror: name: mirror install scripts runs-on: ubuntu-latest diff --git a/tests/install_ps1_test.ps1 b/tests/install_ps1_test.ps1 new file mode 100644 index 0000000..353d532 --- /dev/null +++ b/tests/install_ps1_test.ps1 @@ -0,0 +1,171 @@ +# Behavior tests for install.ps1. Hermetic: release archives are built locally +# from a stub program and served by an Invoke-WebRequest stand-in, so no +# network is touched. Needs Windows and Go on PATH. +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$InstallScript = Join-Path $Root "install.ps1" +$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "install-ps1-test-$([System.Guid]::NewGuid().ToString('N'))" +$Fixtures = Join-Path $TmpDir "fixtures" +New-Item -ItemType Directory -Path $Fixtures -Force | Out-Null + +$Arch = switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "x86_64" } + "ARM64" { "arm64" } + default { throw "unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } +} +$Archive = "flashduty-cli_Windows_${Arch}.zip" + +# Stub CLI: prints its build version; `sleep` keeps it running so the +# installed .exe is locked while a reinstall happens. +$StubSource = Join-Path $TmpDir "stub.go" +Set-Content -Path $StubSource -Encoding ascii -Value @' +package main + +import ( + "fmt" + "os" + "time" +) + +var version = "dev" + +func main() { + if len(os.Args) > 1 && os.Args[1] == "sleep" { + time.Sleep(10 * time.Minute) + } + fmt.Println("stub " + version) +} +'@ + +# Each release is //{archive, checksums.txt}, the layout +# install.ps1 downloads from /releases/download//. +function New-Release($Version) { + $dir = Join-Path $Fixtures $Version + $build = Join-Path $dir "build" + New-Item -ItemType Directory -Path $build -Force | Out-Null + & go build -ldflags "-X main.version=$Version" -o (Join-Path $build "flashduty-cli.exe") $StubSource + if ($LASTEXITCODE -ne 0) { throw "go build failed for $Version" } + $zip = Join-Path $dir $Archive + Compress-Archive -Path (Join-Path $build "flashduty-cli.exe") -DestinationPath $zip -Force + $sum = (Get-FileHash -Path $zip -Algorithm SHA256).Hash.ToLower() + Set-Content -Path (Join-Path $dir "checksums.txt") -Encoding ascii -Value "$sum $Archive" +} + +# Stand-in for the cmdlet: functions take precedence over cmdlets, so the +# installer run below in this session downloads from the local fixtures. +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing) + if ($Uri -notmatch '/releases/download/([^/]+)/([^/]+)$') { throw "unexpected URL: $Uri" } + $src = Join-Path (Join-Path $Fixtures $Matches[1]) $Matches[2] + if (-not (Test-Path $src)) { throw "no fixture for URL: $Uri" } + Copy-Item -Path $src -Destination $OutFile -Force +} + +function Invoke-Installer($InstallDir, $Version, $InstalledName) { + $env:MIRROR_URL = "https://mirror.example/flashduty-cli" + $env:FLASHDUTY_VERSION = $Version + $env:FLASHDUTY_INSTALL_DIR = $InstallDir + $env:INSTALLED_NAME = $InstalledName + & $InstallScript | Out-Null +} + +function Assert-Runs($Exe, $Version) { + $out = (& $Exe | Out-String).Trim() + if ($out -ne "stub $Version") { throw "$Exe printed '$out', want 'stub $Version'" } +} + +function Assert-ExactName($Dir, $Name) { + $names = @(Get-ChildItem -Path $Dir -File | Where-Object { $_.Name -notlike "*.old" } | ForEach-Object { $_.Name }) + if ($names.Count -ne 1 -or $names[0] -cne $Name) { + throw "files in ${Dir}: [$($names -join ', ')], want exactly [$Name]" + } +} + +function Get-OldCopies($Dir, $Name) { + @(Get-ChildItem -Path $Dir -File -Filter "$Name*.old") +} + +function Test-Case($Name, [scriptblock]$Body) { + try { + & $Body + Write-Host "PASS: $Name" + } catch { + Write-Host "FAIL: $Name -- $_" + $script:Failed++ + } +} + +$Failed = 0 +$Sleepers = @() +$SavedUserPath = [Environment]::GetEnvironmentVariable("Path", "User") +try { + New-Release "v1.0.0" + New-Release "v2.0.0" + New-Release "v3.0.0" + + Test-Case "default name installs flashduty.exe" { + $dir = Join-Path $TmpDir "default" + Invoke-Installer $dir "v1.0.0" "" + Assert-ExactName $dir "flashduty.exe" + Assert-Runs (Join-Path $dir "flashduty.exe") "v1.0.0" + } + + Test-Case "INSTALLED_NAME=fduty installs fduty.exe" { + $dir = Join-Path $TmpDir "fduty" + Invoke-Installer $dir "v1.0.0" "fduty" + Assert-ExactName $dir "fduty.exe" + Assert-Runs (Join-Path $dir "fduty.exe") "v1.0.0" + } + + Test-Case "INSTALLED_NAME=FDUTY.EXE installs FDUTY.exe" { + $dir = Join-Path $TmpDir "fduty-upper" + Invoke-Installer $dir "v1.0.0" "FDUTY.EXE" + Assert-ExactName $dir "FDUTY.exe" + Assert-Runs (Join-Path $dir "FDUTY.exe") "v1.0.0" + } + + $runDir = Join-Path $TmpDir "running" + $runExe = Join-Path $runDir "flashduty.exe" + + Test-Case "reinstall replaces the installed exe while it is running" { + Invoke-Installer $runDir "v1.0.0" "" + $script:Sleepers += Start-Process -FilePath $runExe -ArgumentList "sleep" -PassThru -WindowStyle Hidden + Start-Sleep -Seconds 1 + Invoke-Installer $runDir "v2.0.0" "" + Assert-Runs $runExe "v2.0.0" + if ($script:Sleepers[-1].HasExited) { throw "running v1.0.0 process exited during reinstall" } + if ((Get-OldCopies $runDir "flashduty.exe").Count -ne 1) { throw "want one moved-aside copy of the running exe" } + } + + Test-Case "reinstall while the moved-aside copy is still running" { + # The v1.0.0 process from the previous case still runs from the + # moved-aside file, which therefore cannot be deleted or replaced. + $script:Sleepers += Start-Process -FilePath $runExe -ArgumentList "sleep" -PassThru -WindowStyle Hidden + Start-Sleep -Seconds 1 + Invoke-Installer $runDir "v3.0.0" "" + Assert-Runs $runExe "v3.0.0" + Assert-ExactName $runDir "flashduty.exe" + } + + Test-Case "a stale moved-aside copy from an earlier install is cleaned up" { + $dir = Join-Path $TmpDir "stale" + Invoke-Installer $dir "v1.0.0" "" + Invoke-Installer $dir "v2.0.0" "" + Invoke-Installer $dir "v3.0.0" "" + Assert-Runs (Join-Path $dir "flashduty.exe") "v3.0.0" + $old = Get-OldCopies $dir "flashduty.exe" + if ($old.Count -ne 1) { throw "want one moved-aside copy after three installs, got $($old.Count): $($old.Name -join ', ')" } + } +} finally { + $Sleepers | Where-Object { -not $_.HasExited } | Stop-Process -Force -ErrorAction SilentlyContinue + [Environment]::SetEnvironmentVariable("Path", $SavedUserPath, "User") + Start-Sleep -Seconds 1 + Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($Failed -gt 0) { + Write-Host "$Failed case(s) failed" + exit 1 +} +Write-Host "all install.ps1 cases passed" From 1a9ab15f8b023df45448a363af03321e2a968953 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 06:17:46 -0700 Subject: [PATCH 2/2] fix(install.ps1): reinstall while a moved-aside copy is still running Each reinstall moved the existing exe to the fixed name .exe.old. If the copy moved aside by an earlier reinstall was still running, that file could be neither deleted nor replaced, so the next reinstall failed with "Cannot create a file when that file already exists". Move the exe aside under a unique name instead and delete earlier copies that are no longer running. --- install.ps1 | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index 96a1827..fde7207 100644 --- a/install.ps1 +++ b/install.ps1 @@ -156,11 +156,13 @@ try { $DestPath = Join-Path $InstallDir $InstalledName if (Test-Path $DestPath) { - # A running .exe can't be overwritten but can be renamed: move it aside - # so `update` can replace the binary it is running from. - $OldPath = "$DestPath.old" - Remove-Item -Path $OldPath -Force -ErrorAction SilentlyContinue - Move-Item -Path $DestPath -Destination $OldPath -Force + # A running .exe can't be overwritten or deleted but can be renamed: + # move it aside under a unique name so `update` can replace the binary + # it is running from. Copies moved aside earlier are deleted; one that + # is still running stays locked and is left for a later install. + Get-ChildItem -Path $InstallDir -Filter "$InstalledName.*.old" -File | + Remove-Item -Force -ErrorAction SilentlyContinue + Move-Item -Path $DestPath -Destination "$DestPath.$([System.Guid]::NewGuid().ToString('N')).old" } Move-Item -Path $BinaryPath -Destination $DestPath -Force