From d075c69f9bee19be546aa3c9a90da423dbad7a8a Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:15:01 +0800 Subject: [PATCH 01/11] docs: design official Firefox and NVDA installs Co-authored-by: Codex --- ...official-firefox-nvda-installers-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md diff --git a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md new file mode 100644 index 0000000..377bbd0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md @@ -0,0 +1,136 @@ +# Official Firefox and NVDA Installers Design + +## Goal + +Change the Windows accessibility AMI provisioning flow so that every build +installs the current stable releases of Firefox and NVDA directly from their +official publishers instead of obtaining either package through Chocolatey. + +Firefox must be the Traditional Chinese (`zh-TW`) 64-bit stable release. NVDA +must be the current stable release; beta and release-candidate builds are not +eligible. + +## Current State + +`scripts/windows-a11y/install-software.ps1` currently bootstraps Chocolatey and +runs `choco upgrade` for the `firefox` and `nvda` packages. Chrome already uses +an official evergreen MSI and validates its Authenticode publisher. The AMI +workflow reads `VERSION_FIREFOX` and `VERSION_NVDA` from the script output and +writes those values into AMI tags. + +Although this environment is commonly described as the Windows 11 AMI, the +current workflow builds from AWS's Traditional Chinese Windows Server 2025 +base image. This change does not alter the base image or any other provisioning +behavior. + +## Selected Approach + +Use each publisher's stable, evergreen download surface at AMI build time: + +- Firefox: request Mozilla's official redirect endpoint with + `product=firefox-latest-ssl`, `os=win64`, and `lang=zh-TW`. +- NVDA: read the official `https://download.nvaccess.org/releases/stable/` + directory and select the installer whose filename matches + `nvda_.exe`. + +The NVDA filename rule permits numeric releases such as `2026.1` and +`2026.1.1`. It rejects filenames containing `alpha`, `beta`, `rc`, or any other +non-numeric version suffix. Resolution must fail if there is not exactly one +matching installer, rather than guessing among ambiguous results. + +This approach is preferred over parsing product marketing pages because the +evergreen endpoint and stable release directory are narrower, machine-oriented +publisher surfaces. Pinning URLs in the repository was rejected because it +would require manual updates and would not meet the requirement to install the +latest stable release on every AMI build. + +## Installation Flow + +The PowerShell script will define focused helpers for downloading with bounded +retries, validating Authenticode signatures, resolving the NVDA stable +installer, and running installers while checking their exit codes. The script +will retain a single orchestration entry point so its current SSM invocation +does not change. + +For each product: + +1. Create or reuse the existing temporary installer directory. +2. Resolve the official stable download URL. +3. Download the installer over HTTPS, trying at most three times and waiting + 15 seconds and then 30 seconds before the two retries. +4. Require an Authenticode status of `Valid` and an expected publisher: + `Mozilla Corporation` for Firefox and `NV Access Limited` for NVDA. +5. Run the installer silently and wait for completion: Firefox with `-ms` and + NVDA with `--install-silent`. +6. Accept exit code `0` from both executable installers; otherwise fail the + provisioning command with the exit code and available diagnostic context. +7. Remove the downloaded installer after successful installation. + +Firefox will use Mozilla's supported silent-install switch. NVDA will use its +silent install command and install system-wide at the existing executable +location expected by `verify-environment.ps1`. + +There is no Chocolatey fallback. An unavailable official endpoint, malformed +stable listing, invalid signature, unexpected publisher, or failed installer +must stop the AMI build so an unverified or stale package is never baked into +the image. + +## Compatibility and Outputs + +The existing executable checks remain authoritative: + +- Firefox: `C:\Program Files\Mozilla Firefox\firefox.exe` +- NVDA: `C:\Program Files (x86)\NVDA\nvda.exe` + +After installation, the script will continue reading product/file version +metadata from those executables and emitting the existing lines: + +```text +VERSION_FIREFOX= +VERSION_NVDA= +``` + +Consequently, `.github/workflows/build-windows-a11y-ami.yml`, its output +parsing, AMI tags, and `verify-environment.ps1` require no interface changes. +The now-unused Chocolatey bootstrap and package-upgrade helper will be removed. + +## Error Handling and Logging + +Messages will identify the product, operation, attempt number, and terminal +failure without printing credentials or unrelated environment data. Retry is +limited to transient download failures. Signature failures, ambiguous NVDA +listings, and installer failures are deterministic and will fail immediately. + +Installer processes will be awaited before executable/version checks run, so +the existing verification cannot race an installation still in progress. + +## Testing + +Pester tests in +`scripts/windows-a11y/tests/install-software.Tests.ps1` will dot-source the +script without executing its orchestration entry point and cover the following +observable behavior: + +- the Firefox request uses Mozilla's official latest-stable endpoint with + `win64` and `zh-TW`; +- NVDA resolution accepts numeric stable filenames, including patch releases; +- NVDA resolution rejects beta and release-candidate filenames; +- zero or multiple eligible NVDA installers produce a clear failure; +- signature validation accepts only `Valid` signatures from the configured + publisher and rejects invalid or unexpected signatures; +- installer exit-code validation rejects failure codes; +- the public `VERSION_FIREFOX` and `VERSION_NVDA` output contract remains + unchanged. + +The implementation will follow a red-green cycle for each behavior. Final +verification will include the focused PowerShell tests, PowerShell syntax +validation, and inspection of the resulting diff for accidental workflow or +Chocolatey dependencies. + +## Out of Scope + +- Changing the Windows base AMI or AWS infrastructure. +- Changing Chrome installation behavior. +- Installing Firefox ESR, Beta, Developer Edition, or Nightly. +- Installing NVDA alpha, beta, or release-candidate builds. +- Adding a third-party package-manager fallback. From 390ba996951825f975d19d7a144abd75da2c729c Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:16:26 +0800 Subject: [PATCH 02/11] docs: correct Firefox silent install switch Co-authored-by: Codex --- .../specs/2026-08-06-official-firefox-nvda-installers-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md index 377bbd0..370a8b1 100644 --- a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md +++ b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md @@ -60,7 +60,7 @@ For each product: 15 seconds and then 30 seconds before the two retries. 4. Require an Authenticode status of `Valid` and an expected publisher: `Mozilla Corporation` for Firefox and `NV Access Limited` for NVDA. -5. Run the installer silently and wait for completion: Firefox with `-ms` and +5. Run the installer silently and wait for completion: Firefox with `/S` and NVDA with `--install-silent`. 6. Accept exit code `0` from both executable installers; otherwise fail the provisioning command with the exit code and available diagnostic context. From bfdc8c472f94acf67e5bd966c392682c6e349df1 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:20:01 +0800 Subject: [PATCH 03/11] docs: plan official Firefox and NVDA installs Co-authored-by: Codex --- ...-08-06-official-firefox-nvda-installers.md | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md diff --git a/docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md b/docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md new file mode 100644 index 0000000..c5cb912 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md @@ -0,0 +1,514 @@ +# Official Firefox and NVDA Installers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every Windows accessibility AMI build install the current stable Firefox zh-TW 64-bit release and current stable NVDA release directly from their official publishers. + +**Architecture:** Keep `install-software.ps1` as the SSM entry point, but give it a `-SkipExecution` test seam and focused resolver, download, signature, installer, and version-output functions. Pester tests dot-source the real script and mock only Windows/network boundaries; the workflow-facing version output remains unchanged. + +**Tech Stack:** Windows PowerShell 5.1-compatible PowerShell, Pester 5.5+, Authenticode, Mozilla and NV Access HTTPS download services, AWS SSM/GitHub Actions. + +## Global Constraints + +- Firefox is the latest stable `zh-TW` 64-bit release; exclude ESR, Beta, Developer Edition, and Nightly. +- NVDA is the latest stable numeric release; exclude alpha, beta, and release-candidate builds. +- Download only from `download.mozilla.org` and `download.nvaccess.org`. +- Require a valid Authenticode signature from `Mozilla Corporation` or `NV Access Limited`, respectively. +- Do not fall back to Chocolatey or another third-party package manager. +- Retry network operations at most three times, waiting 15 seconds and then 30 seconds. +- Preserve `VERSION_GOOGLECHROME`, `VERSION_FIREFOX`, and `VERSION_NVDA` output lines consumed by the AMI workflow. +- Do not change the Windows Server 2025 base AMI, Chrome behavior, AWS infrastructure, or executable verification paths. +- Use Firefox `/S` and NVDA `--install-silent`; executable installer success is exit code `0` only. +- Every Codex-created commit includes `Co-authored-by: Codex `. + +--- + +## File Structure + +- Modify `scripts/windows-a11y/install-software.ps1`: official-source resolution, security checks, silent install orchestration, test seam, and existing version output. +- Create `scripts/windows-a11y/tests/install-software.Tests.ps1`: Pester behavior tests for official URLs, stable-release filtering, retries, signatures, installer commands, and output compatibility. +- Modify `docs/windows-a11y-aws-manual-setup.md`: replace the obsolete Chocolatey outbound-network description with the three official publishers. + +### Task 1: Official artifact resolution and validation helpers + +**Files:** +- Modify: `scripts/windows-a11y/install-software.ps1:1-87` +- Create: `scripts/windows-a11y/tests/install-software.Tests.ps1` + +**Interfaces:** +- Produces: `Get-FirefoxInstallerUri() -> [Uri]` +- Produces: `Get-NvdaStableInstallerUri([string] $Content, [Uri] $BaseUri) -> [Uri]` +- Produces: `Invoke-WebRequestWithRetry([Uri] $Uri, [string] $OutFile, [int] $MaxAttempts = 3) -> response or void` +- Produces: `Assert-AuthenticodePublisher([string] $Path, [string] $ProductName, [string] $PublisherPattern) -> void` +- Produces: `Assert-InstallerExitCode([string] $ProductName, [int] $ExitCode) -> void` + +- [ ] **Step 1: Add failing resolver and validation tests** + +Create `scripts/windows-a11y/tests/install-software.Tests.ps1` with these initial tests: + +```powershell +BeforeAll { + if (-not (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + function global:Get-AuthenticodeSignature { + param([string]$FilePath) + throw "Get-AuthenticodeSignature must be mocked on non-Windows hosts: $FilePath" + } + } + . (Join-Path $PSScriptRoot '..\install-software.ps1') -SkipExecution +} + +Describe 'official stable installer resolution' { + It 'returns the Mozilla latest stable zh-TW win64 endpoint' { + (Get-FirefoxInstallerUri).AbsoluteUri | Should -BeExactly ` + 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + } + + It 'accepts one numeric NVDA stable patch release' { + $content = 'nvda_2026.1.1.exe' + $uri = Get-NvdaStableInstallerUri -Content $content + $uri.AbsoluteUri | Should -BeExactly ` + 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' + } + + It 'rejects NVDA beta and release-candidate installers' { + $content = @' +beta +rc +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*exactly one numeric stable NVDA installer*' + } + + It 'rejects an ambiguous stable listing' { + $content = @' +first +second +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*found 2*' + } +} + +Describe 'publisher and exit-code validation' { + It 'accepts a valid expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=Mozilla Corporation, O=Mozilla Corporation, C=US' + } + } + } + { Assert-AuthenticodePublisher -Path 'firefox.exe' -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' } | + Should -Not -Throw + } + + It 'rejects an unexpected publisher even when the signature is valid' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Unexpected, O=Unexpected, C=US' } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects an invalid signature from the expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'HashMismatch' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=NV Access Limited, O=NV Access Limited, C=AU' + } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects a nonzero executable installer exit code' { + { Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode 1 } | + Should -Throw '*exit code 1*' + } +} + +Describe 'bounded official download retries' { + It 'retries twice and succeeds on the third attempt' { + $script:attempt = 0 + Mock Invoke-WebRequest { + $script:attempt++ + if ($script:attempt -lt 3) { throw 'temporary failure' } + } + Mock Start-Sleep + + Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + } +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```powershell +pwsh -NoProfile -Command "Invoke-Pester -Path './scripts/windows-a11y/tests/install-software.Tests.ps1' -Output Detailed" +``` + +Expected: FAIL because `install-software.ps1` has no `SkipExecution` parameter and the resolver, retry, and validation functions do not exist. This workstation currently has neither PowerShell nor Pester. With user approval, install them using `brew install --cask powershell`, followed by `pwsh -NoProfile -Command "Install-Module Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force"`; do not substitute text-matching tests. + +- [ ] **Step 3: Add the test seam and minimal helper implementations** + +Change the script parameter and replace the Chocolatey bootstrap/helper with these behaviors: + +```powershell +[CmdletBinding()] +param([switch]$SkipExecution) + +$ErrorActionPreference = 'Stop' +$logPrefix = '[install-software]' +$nvdaStableBaseUri = [Uri]'https://download.nvaccess.org/releases/stable/' + +function Get-FirefoxInstallerUri { + return [Uri]'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' +} + +function Get-NvdaStableInstallerUri { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Uri]$BaseUri = $nvdaStableBaseUri + ) + + $hrefPattern = 'href=["''](?nvda_(?\d{4}\.\d+(?:\.\d+)?)\.exe)["'']' + $matches = @([regex]::Matches($Content, $hrefPattern, 'IgnoreCase')) + if ($matches.Count -ne 1) { + throw "$logPrefix Expected exactly one numeric stable NVDA installer, found $($matches.Count)." + } + return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value) +} + +function Assert-AuthenticodePublisher { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ProductName, + [Parameter(Mandatory = $true)][string]$PublisherPattern + ) + $signature = Get-AuthenticodeSignature -FilePath $Path + $publisher = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { + '' + } + if ($signature.Status -ne 'Valid' -or $publisher -notmatch $PublisherPattern) { + throw "$logPrefix $ProductName signature verification failed (status: $($signature.Status), publisher: $publisher)" + } +} + +function Assert-InstallerExitCode { + param([string]$ProductName, [int]$ExitCode) + if ($ExitCode -ne 0) { + throw "$logPrefix $ProductName installation failed with exit code $ExitCode." + } +} +``` + +Implement the retry helper without catching validation or installer errors: + +```powershell +function Invoke-WebRequestWithRetry { + param( + [Parameter(Mandatory = $true)][Uri]$Uri, + [string]$OutFile, + [int]$MaxAttempts = 3 + ) + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $parameters = @{ Uri = $Uri; UseBasicParsing = $true } + if ($OutFile) { $parameters.OutFile = $OutFile } + return Invoke-WebRequest @parameters + } catch { + if ($attempt -eq $MaxAttempts) { + throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)" + } + Start-Sleep -Seconds (15 * $attempt) + } + } +} +``` + +- [ ] **Step 4: Run tests and verify GREEN** + +Run the same `Invoke-Pester` command. Expected: all Task 1 tests PASS with no warnings. + +- [ ] **Step 5: Commit Task 1** + +```bash +git add scripts/windows-a11y/install-software.ps1 scripts/windows-a11y/tests/install-software.Tests.ps1 +git commit -m "test: cover official Windows installer sources" -m "Co-authored-by: Codex " +``` + +### Task 2: Install Firefox and NVDA from official sources + +**Files:** +- Modify: `scripts/windows-a11y/install-software.ps1:18-156` +- Modify: `scripts/windows-a11y/tests/install-software.Tests.ps1` + +**Interfaces:** +- Consumes: all Task 1 resolver, retry, signature, and exit-code helpers. +- Produces: `Install-Firefox() -> void` +- Produces: `Install-Nvda() -> void` +- Produces: `Write-InstalledSoftwareVersions([Collections.IDictionary] $SoftwareExecutables) -> void` +- Produces: `Invoke-InstallSoftware() -> void`, called only when `-SkipExecution` is absent. + +- [ ] **Step 1: Add failing download retry and product orchestration tests** + +Append tests that mock network/process boundaries but assert the real orchestration contract: + +```powershell +Describe 'official product installers' { + BeforeEach { + Mock New-Item + Mock Remove-Item + Mock Invoke-WebRequestWithRetry + Mock Assert-AuthenticodePublisher + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + Mock Assert-InstallerExitCode + } + + It 'downloads and silently installs Firefox zh-TW win64' { + Install-Firefox + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'Firefox' -and + $PublisherPattern -eq '(^|, )O=Mozilla Corporation(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '/S' -and $Wait -and $PassThru + } + } + + It 'resolves and silently installs the official stable NVDA build' { + Mock Invoke-WebRequestWithRetry { + [pscustomobject]@{ Content = 'download' } + } -ParameterFilter { -not $OutFile } + + Install-Nvda + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'NVDA' -and + $PublisherPattern -eq '(^|, )O=NV Access Limited(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '--install-silent' -and $Wait -and $PassThru + } + } +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run the focused Pester command. Expected: the new product tests fail because `Install-Firefox` and `Install-Nvda` do not exist. + +- [ ] **Step 3: Implement the minimal official installers** + +Implement both functions using the shared temporary directory: + +```powershell +function Install-Firefox { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + $installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} + +function Install-Nvda { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri + $installerUri = Get-NvdaStableInstallerUri -Content $listing.Content + $installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath)) + Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'NVDA' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} +``` + +Keep `Install-GoogleChrome` behavior unchanged. Replace the Chocolatey package loop with `Install-Firefox`, `Install-Nvda`, and `Install-GoogleChrome` calls inside `Invoke-InstallSoftware`. + +- [ ] **Step 4: Add a failing version-output compatibility test** + +Append this test, then run it and verify RED because the version loop is not yet extracted: + +```powershell +Describe 'workflow version output contract' { + It 'emits the three existing VERSION keys' { + Mock Test-Path { $true } + Mock Get-Item { + [pscustomobject]@{ + VersionInfo = [pscustomobject]@{ + ProductVersion = '1.2.3' + FileVersion = '1.2.3.0' + } + } + } + $executables = [ordered]@{ + GOOGLECHROME = 'C:\Google\chrome.exe' + FIREFOX = 'C:\Mozilla Firefox\firefox.exe' + NVDA = 'C:\NVDA\nvda.exe' + } + + $output = @(Write-InstalledSoftwareVersions -SoftwareExecutables $executables) + + $output | Should -Contain 'VERSION_GOOGLECHROME=1.2.3' + $output | Should -Contain 'VERSION_FIREFOX=1.2.3' + $output | Should -Contain 'VERSION_NVDA=1.2.3' + @($output | Where-Object { $_ -like 'VERSION_*=*' }).Count | Should -Be 3 + } +} +``` + +- [ ] **Step 5: Extract version output and guard execution** + +Move the existing executable checks/version loop into this function: + +```powershell +function Write-InstalledSoftwareVersions { + param( + [Parameter(Mandatory = $true)] + [Collections.IDictionary]$SoftwareExecutables + ) + foreach ($software in $SoftwareExecutables.GetEnumerator()) { + if (-not (Test-Path $software.Value)) { + throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" + } + $versionInfo = (Get-Item $software.Value).VersionInfo + $version = if ($versionInfo.ProductVersion) { + $versionInfo.ProductVersion + } else { + $versionInfo.FileVersion + } + Write-Output "VERSION_$($software.Key)=$version" + } +} +``` + +In `Invoke-InstallSoftware`, preserve the existing Chrome wait/log-tail behavior and build the same ordered dictionary: + +```powershell +function Invoke-InstallSoftware { + Install-Firefox + Install-Nvda + Install-GoogleChrome + + Write-Output "$logPrefix Installed package versions:" + $chromeExecutable = Wait-GoogleChromeExecutable + if (-not $chromeExecutable) { + $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' + $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 ` + -ErrorAction SilentlyContinue) -join [Environment]::NewLine + throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" + } + $softwareExecutables = [ordered]@{ + GOOGLECHROME = $chromeExecutable + FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' + NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' + } + Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables +} +``` + +End the script with: + +```powershell +if (-not $SkipExecution) { + Invoke-InstallSoftware +} +``` + +Delete the Chocolatey bootstrap, `$chocoPath`, `$successfulExitCodes`, `Install-OrUpgradePackage`, and the `@('firefox', 'nvda')` loop. + +- [ ] **Step 6: Run the complete Pester file and verify GREEN** + +Run: + +```powershell +pwsh -NoProfile -Command "Invoke-Pester -Path './scripts/windows-a11y/tests/install-software.Tests.ps1' -Output Detailed" +``` + +Expected: all tests PASS, with no download or installer launched because all external boundaries are mocked. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add scripts/windows-a11y/install-software.ps1 scripts/windows-a11y/tests/install-software.Tests.ps1 +git commit -m "feat: install official Firefox and NVDA releases" -m "Co-authored-by: Codex " +``` + +### Task 3: Documentation and end-to-end static verification + +**Files:** +- Modify: `docs/windows-a11y-aws-manual-setup.md:27-29` +- Verify: `.github/workflows/build-windows-a11y-ami.yml:94-101` +- Verify: `scripts/windows-a11y/verify-environment.ps1:30-33` + +**Interfaces:** +- Consumes: unchanged `VERSION_*` output and executable paths from Task 2. +- Produces: operator documentation that names the actual official outbound services. + +- [ ] **Step 1: Update the outbound-network documentation** + +Change the security-group explanation from “Windows Update, Chocolatey, and the SSM agent” to “Windows Update, Google, Mozilla, NV Access, and the SSM agent.” Do not change AWS setup instructions or variables. + +- [ ] **Step 2: Run all behavioral and syntax verification** + +Run the complete Pester file again, then parse the production script: + +```powershell +pwsh -NoProfile -Command "Invoke-Pester -Path './scripts/windows-a11y/tests/install-software.Tests.ps1' -Output Detailed" +pwsh -NoProfile -Command '$errors = $null; [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "./scripts/windows-a11y/install-software.ps1"), [ref]$null, [ref]$errors); if ($errors.Count) { $errors | Out-String | Write-Error; exit 1 }' +``` + +Expected: Pester reports zero failed tests and the parser command exits `0` without errors. + +- [ ] **Step 3: Verify scope and compatibility from the repository root** + +```bash +rg -n "choco|chocolatey|Install-OrUpgradePackage" scripts/windows-a11y/install-software.ps1 +rg -n "firefox-latest-ssl.*os=win64.*lang=zh-TW|download\.nvaccess\.org/releases/stable" scripts/windows-a11y/install-software.ps1 +rg -n "VERSION_GOOGLECHROME|VERSION_FIREFOX|VERSION_NVDA" scripts/windows-a11y/install-software.ps1 .github/workflows/build-windows-a11y-ami.yml +git diff --check +git status --short +``` + +Expected: the first command has no matches; the official endpoints and all three version keys are present; `git diff --check` exits `0`; only the intended script, test, and documentation files are changed. + +- [ ] **Step 4: Commit Task 3** + +```bash +git add docs/windows-a11y-aws-manual-setup.md +git commit -m "docs: describe official Windows software sources" -m "Co-authored-by: Codex " +``` + +- [ ] **Step 5: Perform final verification before reporting completion** + +Run the full Pester suite and PowerShell parser command fresh after all commits, then run `git status --short` and `git log -5 --oneline`. Report the exact test count, parser exit status, commits, and any limitation that the real installers were not executed outside an AMI build. Do not claim the live AMI build succeeds until the GitHub Actions workflow has actually built and verified an AMI. From 684d4c38de6fa79d21fea9806e4f61f7449c2c8d Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:27:56 +0800 Subject: [PATCH 04/11] test: cover official Windows installer sources Co-authored-by: Codex --- scripts/windows-a11y/install-software.ps1 | 129 +++++++++++++----- .../tests/install-software.Tests.ps1 | 106 ++++++++++++++ 2 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 scripts/windows-a11y/tests/install-software.Tests.ps1 diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 index fe62be5..83b8f68 100644 --- a/scripts/windows-a11y/install-software.ps1 +++ b/scripts/windows-a11y/install-software.ps1 @@ -1,18 +1,83 @@ [CmdletBinding()] -param() +param([switch]$SkipExecution) $ErrorActionPreference = 'Stop' $logPrefix = '[install-software]' +$nvdaStableBaseUri = [Uri]'https://download.nvaccess.org/releases/stable/' -if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Output "$logPrefix Installing Chocolatey..." - Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - $env:Path = "$env:Path;C:\ProgramData\chocolatey\bin" +function Get-FirefoxInstallerUri { + return [Uri]'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' } -$chocoPath = (Get-Command choco -ErrorAction Stop).Source +function Get-NvdaStableInstallerUri { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Uri]$BaseUri = $nvdaStableBaseUri + ) + + $hrefPattern = 'href=["''](?nvda_(?\d{4}\.\d+(?:\.\d+)?)\.exe)["'']' + $matches = @([regex]::Matches($Content, $hrefPattern, 'IgnoreCase')) + if ($matches.Count -ne 1) { + throw "$logPrefix Expected exactly one numeric stable NVDA installer, found $($matches.Count)." + } + return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value) +} + +function Invoke-WebRequestWithRetry { + param( + [Parameter(Mandatory = $true)][Uri]$Uri, + [string]$OutFile, + [int]$MaxAttempts = 3 + ) + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $parameters = @{ Uri = $Uri; UseBasicParsing = $true } + if ($OutFile) { $parameters.OutFile = $OutFile } + return Invoke-WebRequest @parameters + } catch { + if ($attempt -eq $MaxAttempts) { + throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)" + } + Start-Sleep -Seconds (15 * $attempt) + } + } +} + +function Assert-AuthenticodePublisher { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ProductName, + [Parameter(Mandatory = $true)][string]$PublisherPattern + ) + $signature = Get-AuthenticodeSignature -FilePath $Path + $publisher = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { + '' + } + if ($signature.Status -ne 'Valid' -or $publisher -notmatch $PublisherPattern) { + throw "$logPrefix $ProductName signature verification failed (status: $($signature.Status), publisher: $publisher)" + } +} + +function Assert-InstallerExitCode { + param([string]$ProductName, [int]$ExitCode) + if ($ExitCode -ne 0) { + throw "$logPrefix $ProductName installation failed with exit code $ExitCode." + } +} + +if (-not $SkipExecution) { + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Output "$logPrefix Installing Chocolatey..." + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + $env:Path = "$env:Path;C:\ProgramData\chocolatey\bin" + } + + $chocoPath = (Get-Command choco -ErrorAction Stop).Source +} $successfulExitCodes = @(0, 2, 1641, 3010) function Install-GoogleChrome { @@ -124,32 +189,34 @@ function Wait-GoogleChromeExecutable { return $null } -$packages = @('firefox', 'nvda') -foreach ($package in $packages) { - Install-OrUpgradePackage -Package $package -} +if (-not $SkipExecution) { + $packages = @('firefox', 'nvda') + foreach ($package in $packages) { + Install-OrUpgradePackage -Package $package + } -Install-GoogleChrome + Install-GoogleChrome -Write-Output "$logPrefix Installed package versions:" -$chromeExecutable = Wait-GoogleChromeExecutable -if (-not $chromeExecutable) { - $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' - $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine - throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" -} + Write-Output "$logPrefix Installed package versions:" + $chromeExecutable = Wait-GoogleChromeExecutable + if (-not $chromeExecutable) { + $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' + $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine + throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" + } -$softwareExecutables = [ordered]@{ - GOOGLECHROME = $chromeExecutable - FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' - NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' -} -foreach ($software in $softwareExecutables.GetEnumerator()) { - if (-not (Test-Path $software.Value)) { - throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" + $softwareExecutables = [ordered]@{ + GOOGLECHROME = $chromeExecutable + FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' + NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' } + foreach ($software in $softwareExecutables.GetEnumerator()) { + if (-not (Test-Path $software.Value)) { + throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" + } - $versionInfo = (Get-Item $software.Value).VersionInfo - $version = if ($versionInfo.ProductVersion) { $versionInfo.ProductVersion } else { $versionInfo.FileVersion } - Write-Output "VERSION_$($software.Key)=$version" + $versionInfo = (Get-Item $software.Value).VersionInfo + $version = if ($versionInfo.ProductVersion) { $versionInfo.ProductVersion } else { $versionInfo.FileVersion } + Write-Output "VERSION_$($software.Key)=$version" + } } diff --git a/scripts/windows-a11y/tests/install-software.Tests.ps1 b/scripts/windows-a11y/tests/install-software.Tests.ps1 new file mode 100644 index 0000000..0889f35 --- /dev/null +++ b/scripts/windows-a11y/tests/install-software.Tests.ps1 @@ -0,0 +1,106 @@ +BeforeAll { + if (-not (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + function global:Get-AuthenticodeSignature { + param([string]$FilePath) + throw "Get-AuthenticodeSignature must be mocked on non-Windows hosts: $FilePath" + } + } + . (Join-Path $PSScriptRoot '..\install-software.ps1') -SkipExecution +} + +Describe 'official stable installer resolution' { + It 'returns the Mozilla latest stable zh-TW win64 endpoint' { + (Get-FirefoxInstallerUri).AbsoluteUri | Should -BeExactly ` + 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + } + + It 'accepts one numeric NVDA stable patch release' { + $content = 'nvda_2026.1.1.exe' + $uri = Get-NvdaStableInstallerUri -Content $content + $uri.AbsoluteUri | Should -BeExactly ` + 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' + } + + It 'rejects NVDA beta and release-candidate installers' { + $content = @' +beta +rc +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*exactly one numeric stable NVDA installer*' + } + + It 'rejects an ambiguous stable listing' { + $content = @' +first +second +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*found 2*' + } +} + +Describe 'publisher and exit-code validation' { + It 'accepts a valid expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=Mozilla Corporation, O=Mozilla Corporation, C=US' + } + } + } + { Assert-AuthenticodePublisher -Path 'firefox.exe' -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' } | + Should -Not -Throw + } + + It 'rejects an unexpected publisher even when the signature is valid' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Unexpected, O=Unexpected, C=US' } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects an invalid signature from the expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'HashMismatch' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=NV Access Limited, O=NV Access Limited, C=AU' + } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects a nonzero executable installer exit code' { + { Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode 1 } | + Should -Throw '*exit code 1*' + } +} + +Describe 'bounded official download retries' { + It 'retries twice and succeeds on the third attempt' { + $script:attempt = 0 + Mock Invoke-WebRequest { + $script:attempt++ + if ($script:attempt -lt 3) { throw 'temporary failure' } + } + Mock Start-Sleep + + Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + } +} From e949a542bee5a12e103223c5ba8c47988925f926 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:37:43 +0800 Subject: [PATCH 05/11] fix: bound installer download retries Co-authored-by: Codex --- scripts/windows-a11y/install-software.ps1 | 2 +- scripts/windows-a11y/tests/install-software.Tests.ps1 | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 index 83b8f68..6e5505f 100644 --- a/scripts/windows-a11y/install-software.ps1 +++ b/scripts/windows-a11y/install-software.ps1 @@ -27,7 +27,7 @@ function Invoke-WebRequestWithRetry { param( [Parameter(Mandatory = $true)][Uri]$Uri, [string]$OutFile, - [int]$MaxAttempts = 3 + [ValidateRange(1, 3)][int]$MaxAttempts = 3 ) for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { diff --git a/scripts/windows-a11y/tests/install-software.Tests.ps1 b/scripts/windows-a11y/tests/install-software.Tests.ps1 index 0889f35..52e4d16 100644 --- a/scripts/windows-a11y/tests/install-software.Tests.ps1 +++ b/scripts/windows-a11y/tests/install-software.Tests.ps1 @@ -88,6 +88,14 @@ Describe 'publisher and exit-code validation' { } Describe 'bounded official download retries' { + It 'rejects download retry counts above three' { + Mock Invoke-WebRequest + + { Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" -MaxAttempts 4 } | + Should -Throw '*MaxAttempts*' + } + It 'retries twice and succeeds on the third attempt' { $script:attempt = 0 Mock Invoke-WebRequest { From 9fe4078cc3c0bdc8d835d0214536cd0ea747a10c Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:41:51 +0800 Subject: [PATCH 06/11] feat: install official Firefox and NVDA releases Co-authored-by: Codex --- scripts/windows-a11y/install-software.ps1 | 105 +++++++++--------- .../tests/install-software.Tests.ps1 | 70 ++++++++++++ 2 files changed, 120 insertions(+), 55 deletions(-) diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 index 6e5505f..98cfc13 100644 --- a/scripts/windows-a11y/install-software.ps1 +++ b/scripts/windows-a11y/install-software.ps1 @@ -67,18 +67,31 @@ function Assert-InstallerExitCode { } } -if (-not $SkipExecution) { - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Output "$logPrefix Installing Chocolatey..." - Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - $env:Path = "$env:Path;C:\ProgramData\chocolatey\bin" - } +function Install-Firefox { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + $installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} - $chocoPath = (Get-Command choco -ErrorAction Stop).Source +function Install-Nvda { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri + $installerUri = Get-NvdaStableInstallerUri -Content $listing.Content + $installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath)) + Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'NVDA' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force } -$successfulExitCodes = @(0, 2, 1641, 3010) function Install-GoogleChrome { $installerUri = 'https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi' @@ -93,7 +106,7 @@ function Install-GoogleChrome { # The Chrome Enterprise URL always points at the current stable MSI, so a # static checksum would become stale. Verify Google's code-signing identity - # instead of bypassing integrity checks with Chocolatey's --ignore-checksums. + # rather than bypassing integrity checks. $signature = Get-AuthenticodeSignature -FilePath $installerPath $publisher = $signature.SignerCertificate.Subject if ($signature.Status -ne 'Valid' -or $publisher -notmatch '(^|, )O=Google LLC(,|$)') { @@ -121,36 +134,6 @@ function Install-GoogleChrome { Remove-Item -Path $installerPath -Force } -function Install-OrUpgradePackage { - param( - [Parameter(Mandatory = $true)] - [string]$Package, - - [int]$MaxAttempts = 3 - ) - - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - Write-Output "$logPrefix Installing/updating $Package (attempt $attempt of $MaxAttempts)..." - - # Do not discard Chocolatey's output: it contains the installer-specific - # error that is needed to diagnose failures from the SSM command log. - & $chocoPath upgrade $Package -y --no-progress --execution-timeout=1200 --ignore-detected-reboot - $exitCode = $LASTEXITCODE - - if ($exitCode -in $successfulExitCodes) { - return - } - - if ($attempt -lt $MaxAttempts) { - $retryDelay = 15 * $attempt - Write-Warning "$logPrefix choco upgrade $Package failed with exit code $exitCode; retrying in $retryDelay seconds." - Start-Sleep -Seconds $retryDelay - } - } - - throw "$logPrefix choco upgrade $Package failed after $MaxAttempts attempts (last exit code: $exitCode)" -} - function Find-GoogleChromeExecutable { $candidates = @( 'C:\Program Files\Google\Chrome\Application\chrome.exe' @@ -189,34 +172,46 @@ function Wait-GoogleChromeExecutable { return $null } -if (-not $SkipExecution) { - $packages = @('firefox', 'nvda') - foreach ($package in $packages) { - Install-OrUpgradePackage -Package $package +function Write-InstalledSoftwareVersions { + param( + [Parameter(Mandatory = $true)] + [Collections.IDictionary]$SoftwareExecutables + ) + foreach ($software in $SoftwareExecutables.GetEnumerator()) { + if (-not (Test-Path $software.Value)) { + throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" + } + $versionInfo = (Get-Item $software.Value).VersionInfo + $version = if ($versionInfo.ProductVersion) { + $versionInfo.ProductVersion + } else { + $versionInfo.FileVersion + } + Write-Output "VERSION_$($software.Key)=$version" } +} +function Invoke-InstallSoftware { + Install-Firefox + Install-Nvda Install-GoogleChrome Write-Output "$logPrefix Installed package versions:" $chromeExecutable = Wait-GoogleChromeExecutable if (-not $chromeExecutable) { $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' - $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine + $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 ` + -ErrorAction SilentlyContinue) -join [Environment]::NewLine throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" } - $softwareExecutables = [ordered]@{ GOOGLECHROME = $chromeExecutable FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' } - foreach ($software in $softwareExecutables.GetEnumerator()) { - if (-not (Test-Path $software.Value)) { - throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" - } + Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables +} - $versionInfo = (Get-Item $software.Value).VersionInfo - $version = if ($versionInfo.ProductVersion) { $versionInfo.ProductVersion } else { $versionInfo.FileVersion } - Write-Output "VERSION_$($software.Key)=$version" - } +if (-not $SkipExecution) { + Invoke-InstallSoftware } diff --git a/scripts/windows-a11y/tests/install-software.Tests.ps1 b/scripts/windows-a11y/tests/install-software.Tests.ps1 index 52e4d16..5e0f006 100644 --- a/scripts/windows-a11y/tests/install-software.Tests.ps1 +++ b/scripts/windows-a11y/tests/install-software.Tests.ps1 @@ -112,3 +112,73 @@ Describe 'bounded official download retries' { Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } } } + +Describe 'official product installers' { + BeforeEach { + $env:TEMP = $TestDrive + Mock New-Item + Mock Remove-Item + Mock Invoke-WebRequestWithRetry + Mock Assert-AuthenticodePublisher + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + Mock Assert-InstallerExitCode + } + + It 'downloads and silently installs Firefox zh-TW win64' { + Install-Firefox + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'Firefox' -and + $PublisherPattern -eq '(^|, )O=Mozilla Corporation(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '/S' -and $Wait -and $PassThru + } + } + + It 'resolves and silently installs the official stable NVDA build' { + Mock Invoke-WebRequestWithRetry { + [pscustomobject]@{ Content = 'download' } + } -ParameterFilter { -not $OutFile } + + Install-Nvda + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'NVDA' -and + $PublisherPattern -eq '(^|, )O=NV Access Limited(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '--install-silent' -and $Wait -and $PassThru + } + } +} + +Describe 'workflow version output contract' { + It 'emits the three existing VERSION keys' { + Mock Test-Path { $true } + Mock Get-Item { + [pscustomobject]@{ + VersionInfo = [pscustomobject]@{ + ProductVersion = '1.2.3' + FileVersion = '1.2.3.0' + } + } + } + $executables = [ordered]@{ + GOOGLECHROME = 'C:\Google\chrome.exe' + FIREFOX = 'C:\Mozilla Firefox\firefox.exe' + NVDA = 'C:\NVDA\nvda.exe' + } + + $output = @(Write-InstalledSoftwareVersions -SoftwareExecutables $executables) + + $output | Should -Contain 'VERSION_GOOGLECHROME=1.2.3' + $output | Should -Contain 'VERSION_FIREFOX=1.2.3' + $output | Should -Contain 'VERSION_NVDA=1.2.3' + @($output | Where-Object { $_ -like 'VERSION_*=*' }).Count | Should -Be 3 + } +} From 72033570131af7e13897bbccb24f35ef4e5648d8 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 6 Aug 2026 23:44:27 +0800 Subject: [PATCH 07/11] docs: describe official Windows software sources Co-authored-by: Codex --- docs/windows-a11y-aws-manual-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md index d7e1a25..1e57022 100644 --- a/docs/windows-a11y-aws-manual-setup.md +++ b/docs/windows-a11y-aws-manual-setup.md @@ -28,7 +28,7 @@ reliable than using the localized base image. - Source: `Custom` → enter your office/VPN CIDR block (e.g. `203.0.113.0/24`) — do **not** use `0.0.0.0/0`. - Description: `Office VPN RDP access` 6. **Outbound rules**: leave the default (all traffic allowed) — the instance needs outbound HTTPS for - Windows Update, Chocolatey, and the SSM agent. + Windows Update, Google, Mozilla, NV Access, and the SSM agent. 7. **Tags**: `Name` = `windows-a11y-rdp`. 8. Click **Create security group**. Copy the resulting **Security group ID** (e.g. `sg-0123456789abcdef0`). 9. Record this value — it becomes the `SECURITY_GROUP_ID` GitHub variable in step 5. From d01674bbbf9f0938ec01c813de61a4c6a376c16c Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Fri, 7 Aug 2026 00:13:26 +0800 Subject: [PATCH 08/11] fix: resolve current NVDA executable paths Co-authored-by: Codex --- ...official-firefox-nvda-installers-design.md | 8 ++- scripts/windows-a11y/install-software.ps1 | 23 +++++-- .../tests/install-software.Tests.ps1 | 47 +++++++++++++- .../tests/verify-environment.Tests.ps1 | 19 ++++++ scripts/windows-a11y/verify-environment.ps1 | 62 ++++++++++++------- 5 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 scripts/windows-a11y/tests/verify-environment.Tests.ps1 diff --git a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md index 370a8b1..53618a5 100644 --- a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md +++ b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md @@ -67,8 +67,9 @@ For each product: 7. Remove the downloaded installer after successful installation. Firefox will use Mozilla's supported silent-install switch. NVDA will use its -silent install command and install system-wide at the existing executable -location expected by `verify-environment.ps1`. +silent install command and install system-wide at the current 64-bit executable +location expected by `verify-environment.ps1`. The legacy x86 location remains +an explicit fallback for existing installations. There is no Chocolatey fallback. An unavailable official endpoint, malformed stable listing, invalid signature, unexpected publisher, or failed installer @@ -80,7 +81,8 @@ the image. The existing executable checks remain authoritative: - Firefox: `C:\Program Files\Mozilla Firefox\firefox.exe` -- NVDA: `C:\Program Files (x86)\NVDA\nvda.exe` +- NVDA primary: `C:\Program Files\NVDA\nvda.exe` +- NVDA legacy fallback: `C:\Program Files (x86)\NVDA\nvda.exe` After installation, the script will continue reading product/file version metadata from those executables and emitting the existing lines: diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 index 98cfc13..ed18f90 100644 --- a/scripts/windows-a11y/install-software.ps1 +++ b/scripts/windows-a11y/install-software.ps1 @@ -23,10 +23,20 @@ function Get-NvdaStableInstallerUri { return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value) } +function Find-NvdaExecutable { + $candidates = @( + 'C:\Program Files\NVDA\nvda.exe' + 'C:\Program Files (x86)\NVDA\nvda.exe' + ) + + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} + function Invoke-WebRequestWithRetry { param( [Parameter(Mandatory = $true)][Uri]$Uri, [string]$OutFile, + [string]$Operation = 'download', [ValidateRange(1, 3)][int]$MaxAttempts = 3 ) for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { @@ -38,7 +48,9 @@ function Invoke-WebRequestWithRetry { if ($attempt -eq $MaxAttempts) { throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)" } - Start-Sleep -Seconds (15 * $attempt) + $delaySeconds = 15 * $attempt + Write-Warning "$logPrefix $Operation attempt $attempt of $MaxAttempts failed; retrying in $delaySeconds seconds." + Start-Sleep -Seconds $delaySeconds } } } @@ -71,7 +83,8 @@ function Install-Firefox { $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' $installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe' New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null - Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath + Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath ` + -Operation 'Firefox installer download' Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' ` -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' $process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru @@ -82,10 +95,10 @@ function Install-Firefox { function Install-Nvda { $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null - $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri + $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri -Operation 'NVDA stable listing download' $installerUri = Get-NvdaStableInstallerUri -Content $listing.Content $installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath)) - Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath + Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath -Operation 'NVDA installer download' Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' ` -PublisherPattern '(^|, )O=NV Access Limited(,|$)' $process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru @@ -207,7 +220,7 @@ function Invoke-InstallSoftware { $softwareExecutables = [ordered]@{ GOOGLECHROME = $chromeExecutable FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' - NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' + NVDA = Find-NvdaExecutable } Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables } diff --git a/scripts/windows-a11y/tests/install-software.Tests.ps1 b/scripts/windows-a11y/tests/install-software.Tests.ps1 index 5e0f006..9226f53 100644 --- a/scripts/windows-a11y/tests/install-software.Tests.ps1 +++ b/scripts/windows-a11y/tests/install-software.Tests.ps1 @@ -111,6 +111,43 @@ Describe 'bounded official download retries' { Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } } + + It 'fails after three requests with contextual retry warnings' { + Mock Invoke-WebRequest { throw 'temporary failure' } + Mock Start-Sleep + Mock Write-Warning + + { Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" -Operation 'Firefox installer download' } | + Should -Throw '*failed after 3 attempts*' + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + Should -Invoke Start-Sleep -Times 2 -Exactly + Should -Invoke Write-Warning -Times 1 -ParameterFilter { + $Message -like '*Firefox installer download*attempt 1 of 3*15*' + } + Should -Invoke Write-Warning -Times 1 -ParameterFilter { + $Message -like '*Firefox installer download*attempt 2 of 3*30*' + } + } +} + +Describe 'NVDA executable resolution' { + It 'prefers the current 64-bit NVDA executable path' { + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files\NVDA\nvda.exe' + } + + It 'falls back to the legacy x86 NVDA executable path' { + Mock Test-Path { $false } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files (x86)\NVDA\nvda.exe' + } } Describe 'official product installers' { @@ -127,7 +164,8 @@ Describe 'official product installers' { It 'downloads and silently installs Firefox zh-TW win64' { Install-Firefox Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { - $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' -and + $Operation -eq 'Firefox installer download' } Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { $ProductName -eq 'Firefox' -and @@ -145,7 +183,12 @@ Describe 'official product installers' { Install-Nvda Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { - $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile -and + $Operation -eq 'NVDA stable listing download' + } + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' -and + $Operation -eq 'NVDA installer download' } Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { $ProductName -eq 'NVDA' -and diff --git a/scripts/windows-a11y/tests/verify-environment.Tests.ps1 b/scripts/windows-a11y/tests/verify-environment.Tests.ps1 new file mode 100644 index 0000000..4cf8a87 --- /dev/null +++ b/scripts/windows-a11y/tests/verify-environment.Tests.ps1 @@ -0,0 +1,19 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\verify-environment.ps1') -SkipExecution +} + +Describe 'NVDA verification executable resolution' { + It 'prefers the current 64-bit NVDA executable path' { + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files\NVDA\nvda.exe' + } + + It 'falls back to the legacy x86 NVDA executable path' { + Mock Test-Path { $false } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files (x86)\NVDA\nvda.exe' + } +} diff --git a/scripts/windows-a11y/verify-environment.ps1 b/scripts/windows-a11y/verify-environment.ps1 index 28e2f91..7ae6ec9 100644 --- a/scripts/windows-a11y/verify-environment.ps1 +++ b/scripts/windows-a11y/verify-environment.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([switch]$SkipExecution) $ErrorActionPreference = 'Stop' @@ -25,32 +25,48 @@ function Find-GoogleChromeExecutable { return $candidates | Select-Object -Unique | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 } -$results = [ordered]@{} +function Find-NvdaExecutable { + $candidates = @( + 'C:\Program Files\NVDA\nvda.exe' + 'C:\Program Files (x86)\NVDA\nvda.exe' + ) + + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} -$results.ChromePath = Find-GoogleChromeExecutable -$results.ChromeInstalled = [bool]$results.ChromePath -$results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' -$results.NvdaInstalled = Test-Path 'C:\Program Files (x86)\NVDA\nvda.exe' +function Invoke-EnvironmentVerification { + $results = [ordered]@{} -$rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections -$results.RdpEnabled = ($rdpValue -eq 0) + $results.ChromePath = Find-GoogleChromeExecutable + $results.ChromeInstalled = [bool]$results.ChromePath + $results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' + $nvdaPath = Find-NvdaExecutable + $results.NvdaInstalled = [bool]$nvdaPath -$results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) -$results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) -$results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) -$results.BaseInstalledUiCulture = [System.Globalization.CultureInfo]::InstalledUICulture.Name -$results.DisplayLanguage = Get-SystemPreferredUILanguage -$results.SystemLocale = (Get-WinSystemLocale).Name -$results.DisplayLanguageIsTraditionalChinese = ($results.DisplayLanguage -in @('zh-TW', 'zh-Hant-TW')) -$results.SystemLocaleIsTraditionalChinese = ($results.SystemLocale -eq 'zh-TW') + $rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections + $results.RdpEnabled = ($rdpValue -eq 0) -$checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists','DisplayLanguageIsTraditionalChinese','SystemLocaleIsTraditionalChinese') -$allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) -$results.AllChecksPassed = $allPassed + $results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) + $results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) + $results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) + $results.BaseInstalledUiCulture = [System.Globalization.CultureInfo]::InstalledUICulture.Name + $results.DisplayLanguage = Get-SystemPreferredUILanguage + $results.SystemLocale = (Get-WinSystemLocale).Name + $results.DisplayLanguageIsTraditionalChinese = ($results.DisplayLanguage -in @('zh-TW', 'zh-Hant-TW')) + $results.SystemLocaleIsTraditionalChinese = ($results.SystemLocale -eq 'zh-TW') -$json = $results | ConvertTo-Json -Compress -Write-Output "VERIFY_RESULT_JSON=$json" + $checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists','DisplayLanguageIsTraditionalChinese','SystemLocaleIsTraditionalChinese') + $allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) + $results.AllChecksPassed = $allPassed + + $json = $results | ConvertTo-Json -Compress + Write-Output "VERIFY_RESULT_JSON=$json" + + if (-not $allPassed) { + throw "Environment verification failed: $json" + } +} -if (-not $allPassed) { - throw "Environment verification failed: $json" +if (-not $SkipExecution) { + Invoke-EnvironmentVerification } From af23fa40d04f50f54a34bf8b0ecc1c453eec18a0 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Fri, 7 Aug 2026 00:19:55 +0800 Subject: [PATCH 09/11] chore: exclude superpowers planning docs Co-authored-by: Codex --- ...-08-06-official-firefox-nvda-installers.md | 514 ------------------ ...official-firefox-nvda-installers-design.md | 138 ----- 2 files changed, 652 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md delete mode 100644 docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md diff --git a/docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md b/docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md deleted file mode 100644 index c5cb912..0000000 --- a/docs/superpowers/plans/2026-08-06-official-firefox-nvda-installers.md +++ /dev/null @@ -1,514 +0,0 @@ -# Official Firefox and NVDA Installers Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make every Windows accessibility AMI build install the current stable Firefox zh-TW 64-bit release and current stable NVDA release directly from their official publishers. - -**Architecture:** Keep `install-software.ps1` as the SSM entry point, but give it a `-SkipExecution` test seam and focused resolver, download, signature, installer, and version-output functions. Pester tests dot-source the real script and mock only Windows/network boundaries; the workflow-facing version output remains unchanged. - -**Tech Stack:** Windows PowerShell 5.1-compatible PowerShell, Pester 5.5+, Authenticode, Mozilla and NV Access HTTPS download services, AWS SSM/GitHub Actions. - -## Global Constraints - -- Firefox is the latest stable `zh-TW` 64-bit release; exclude ESR, Beta, Developer Edition, and Nightly. -- NVDA is the latest stable numeric release; exclude alpha, beta, and release-candidate builds. -- Download only from `download.mozilla.org` and `download.nvaccess.org`. -- Require a valid Authenticode signature from `Mozilla Corporation` or `NV Access Limited`, respectively. -- Do not fall back to Chocolatey or another third-party package manager. -- Retry network operations at most three times, waiting 15 seconds and then 30 seconds. -- Preserve `VERSION_GOOGLECHROME`, `VERSION_FIREFOX`, and `VERSION_NVDA` output lines consumed by the AMI workflow. -- Do not change the Windows Server 2025 base AMI, Chrome behavior, AWS infrastructure, or executable verification paths. -- Use Firefox `/S` and NVDA `--install-silent`; executable installer success is exit code `0` only. -- Every Codex-created commit includes `Co-authored-by: Codex `. - ---- - -## File Structure - -- Modify `scripts/windows-a11y/install-software.ps1`: official-source resolution, security checks, silent install orchestration, test seam, and existing version output. -- Create `scripts/windows-a11y/tests/install-software.Tests.ps1`: Pester behavior tests for official URLs, stable-release filtering, retries, signatures, installer commands, and output compatibility. -- Modify `docs/windows-a11y-aws-manual-setup.md`: replace the obsolete Chocolatey outbound-network description with the three official publishers. - -### Task 1: Official artifact resolution and validation helpers - -**Files:** -- Modify: `scripts/windows-a11y/install-software.ps1:1-87` -- Create: `scripts/windows-a11y/tests/install-software.Tests.ps1` - -**Interfaces:** -- Produces: `Get-FirefoxInstallerUri() -> [Uri]` -- Produces: `Get-NvdaStableInstallerUri([string] $Content, [Uri] $BaseUri) -> [Uri]` -- Produces: `Invoke-WebRequestWithRetry([Uri] $Uri, [string] $OutFile, [int] $MaxAttempts = 3) -> response or void` -- Produces: `Assert-AuthenticodePublisher([string] $Path, [string] $ProductName, [string] $PublisherPattern) -> void` -- Produces: `Assert-InstallerExitCode([string] $ProductName, [int] $ExitCode) -> void` - -- [ ] **Step 1: Add failing resolver and validation tests** - -Create `scripts/windows-a11y/tests/install-software.Tests.ps1` with these initial tests: - -```powershell -BeforeAll { - if (-not (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { - function global:Get-AuthenticodeSignature { - param([string]$FilePath) - throw "Get-AuthenticodeSignature must be mocked on non-Windows hosts: $FilePath" - } - } - . (Join-Path $PSScriptRoot '..\install-software.ps1') -SkipExecution -} - -Describe 'official stable installer resolution' { - It 'returns the Mozilla latest stable zh-TW win64 endpoint' { - (Get-FirefoxInstallerUri).AbsoluteUri | Should -BeExactly ` - 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' - } - - It 'accepts one numeric NVDA stable patch release' { - $content = 'nvda_2026.1.1.exe' - $uri = Get-NvdaStableInstallerUri -Content $content - $uri.AbsoluteUri | Should -BeExactly ` - 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' - } - - It 'rejects NVDA beta and release-candidate installers' { - $content = @' -beta -rc -'@ - { Get-NvdaStableInstallerUri -Content $content } | - Should -Throw '*exactly one numeric stable NVDA installer*' - } - - It 'rejects an ambiguous stable listing' { - $content = @' -first -second -'@ - { Get-NvdaStableInstallerUri -Content $content } | - Should -Throw '*found 2*' - } -} - -Describe 'publisher and exit-code validation' { - It 'accepts a valid expected publisher' { - Mock Get-AuthenticodeSignature { - [pscustomobject]@{ - Status = 'Valid' - SignerCertificate = [pscustomobject]@{ - Subject = 'CN=Mozilla Corporation, O=Mozilla Corporation, C=US' - } - } - } - { Assert-AuthenticodePublisher -Path 'firefox.exe' -ProductName 'Firefox' ` - -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' } | - Should -Not -Throw - } - - It 'rejects an unexpected publisher even when the signature is valid' { - Mock Get-AuthenticodeSignature { - [pscustomobject]@{ - Status = 'Valid' - SignerCertificate = [pscustomobject]@{ Subject = 'CN=Unexpected, O=Unexpected, C=US' } - } - } - { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` - -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | - Should -Throw '*signature verification failed*' - } - - It 'rejects an invalid signature from the expected publisher' { - Mock Get-AuthenticodeSignature { - [pscustomobject]@{ - Status = 'HashMismatch' - SignerCertificate = [pscustomobject]@{ - Subject = 'CN=NV Access Limited, O=NV Access Limited, C=AU' - } - } - } - { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` - -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | - Should -Throw '*signature verification failed*' - } - - It 'rejects a nonzero executable installer exit code' { - { Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode 1 } | - Should -Throw '*exit code 1*' - } -} - -Describe 'bounded official download retries' { - It 'retries twice and succeeds on the third attempt' { - $script:attempt = 0 - Mock Invoke-WebRequest { - $script:attempt++ - if ($script:attempt -lt 3) { throw 'temporary failure' } - } - Mock Start-Sleep - - Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` - -OutFile "$TestDrive\example.exe" - - Should -Invoke Invoke-WebRequest -Times 3 -Exactly - Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } - Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } - } -} -``` - -- [ ] **Step 2: Run the focused tests and verify RED** - -Run: - -```powershell -pwsh -NoProfile -Command "Invoke-Pester -Path './scripts/windows-a11y/tests/install-software.Tests.ps1' -Output Detailed" -``` - -Expected: FAIL because `install-software.ps1` has no `SkipExecution` parameter and the resolver, retry, and validation functions do not exist. This workstation currently has neither PowerShell nor Pester. With user approval, install them using `brew install --cask powershell`, followed by `pwsh -NoProfile -Command "Install-Module Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force"`; do not substitute text-matching tests. - -- [ ] **Step 3: Add the test seam and minimal helper implementations** - -Change the script parameter and replace the Chocolatey bootstrap/helper with these behaviors: - -```powershell -[CmdletBinding()] -param([switch]$SkipExecution) - -$ErrorActionPreference = 'Stop' -$logPrefix = '[install-software]' -$nvdaStableBaseUri = [Uri]'https://download.nvaccess.org/releases/stable/' - -function Get-FirefoxInstallerUri { - return [Uri]'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' -} - -function Get-NvdaStableInstallerUri { - param( - [Parameter(Mandatory = $true)][string]$Content, - [Uri]$BaseUri = $nvdaStableBaseUri - ) - - $hrefPattern = 'href=["''](?nvda_(?\d{4}\.\d+(?:\.\d+)?)\.exe)["'']' - $matches = @([regex]::Matches($Content, $hrefPattern, 'IgnoreCase')) - if ($matches.Count -ne 1) { - throw "$logPrefix Expected exactly one numeric stable NVDA installer, found $($matches.Count)." - } - return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value) -} - -function Assert-AuthenticodePublisher { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$ProductName, - [Parameter(Mandatory = $true)][string]$PublisherPattern - ) - $signature = Get-AuthenticodeSignature -FilePath $Path - $publisher = if ($signature.SignerCertificate) { - $signature.SignerCertificate.Subject - } else { - '' - } - if ($signature.Status -ne 'Valid' -or $publisher -notmatch $PublisherPattern) { - throw "$logPrefix $ProductName signature verification failed (status: $($signature.Status), publisher: $publisher)" - } -} - -function Assert-InstallerExitCode { - param([string]$ProductName, [int]$ExitCode) - if ($ExitCode -ne 0) { - throw "$logPrefix $ProductName installation failed with exit code $ExitCode." - } -} -``` - -Implement the retry helper without catching validation or installer errors: - -```powershell -function Invoke-WebRequestWithRetry { - param( - [Parameter(Mandatory = $true)][Uri]$Uri, - [string]$OutFile, - [int]$MaxAttempts = 3 - ) - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - $parameters = @{ Uri = $Uri; UseBasicParsing = $true } - if ($OutFile) { $parameters.OutFile = $OutFile } - return Invoke-WebRequest @parameters - } catch { - if ($attempt -eq $MaxAttempts) { - throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)" - } - Start-Sleep -Seconds (15 * $attempt) - } - } -} -``` - -- [ ] **Step 4: Run tests and verify GREEN** - -Run the same `Invoke-Pester` command. Expected: all Task 1 tests PASS with no warnings. - -- [ ] **Step 5: Commit Task 1** - -```bash -git add scripts/windows-a11y/install-software.ps1 scripts/windows-a11y/tests/install-software.Tests.ps1 -git commit -m "test: cover official Windows installer sources" -m "Co-authored-by: Codex " -``` - -### Task 2: Install Firefox and NVDA from official sources - -**Files:** -- Modify: `scripts/windows-a11y/install-software.ps1:18-156` -- Modify: `scripts/windows-a11y/tests/install-software.Tests.ps1` - -**Interfaces:** -- Consumes: all Task 1 resolver, retry, signature, and exit-code helpers. -- Produces: `Install-Firefox() -> void` -- Produces: `Install-Nvda() -> void` -- Produces: `Write-InstalledSoftwareVersions([Collections.IDictionary] $SoftwareExecutables) -> void` -- Produces: `Invoke-InstallSoftware() -> void`, called only when `-SkipExecution` is absent. - -- [ ] **Step 1: Add failing download retry and product orchestration tests** - -Append tests that mock network/process boundaries but assert the real orchestration contract: - -```powershell -Describe 'official product installers' { - BeforeEach { - Mock New-Item - Mock Remove-Item - Mock Invoke-WebRequestWithRetry - Mock Assert-AuthenticodePublisher - Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } - Mock Assert-InstallerExitCode - } - - It 'downloads and silently installs Firefox zh-TW win64' { - Install-Firefox - Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { - $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' - } - Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { - $ProductName -eq 'Firefox' -and - $PublisherPattern -eq '(^|, )O=Mozilla Corporation(,|$)' - } - Should -Invoke Start-Process -Times 1 -ParameterFilter { - $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '/S' -and $Wait -and $PassThru - } - } - - It 'resolves and silently installs the official stable NVDA build' { - Mock Invoke-WebRequestWithRetry { - [pscustomobject]@{ Content = 'download' } - } -ParameterFilter { -not $OutFile } - - Install-Nvda - Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { - $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile - } - Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { - $ProductName -eq 'NVDA' -and - $PublisherPattern -eq '(^|, )O=NV Access Limited(,|$)' - } - Should -Invoke Start-Process -Times 1 -ParameterFilter { - $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '--install-silent' -and $Wait -and $PassThru - } - } -} -``` - -- [ ] **Step 2: Run the focused tests and verify RED** - -Run the focused Pester command. Expected: the new product tests fail because `Install-Firefox` and `Install-Nvda` do not exist. - -- [ ] **Step 3: Implement the minimal official installers** - -Implement both functions using the shared temporary directory: - -```powershell -function Install-Firefox { - $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' - $installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe' - New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null - Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath - Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' ` - -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' - $process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru - Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode $process.ExitCode - Remove-Item -LiteralPath $installerPath -Force -} - -function Install-Nvda { - $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' - New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null - $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri - $installerUri = Get-NvdaStableInstallerUri -Content $listing.Content - $installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath)) - Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath - Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' ` - -PublisherPattern '(^|, )O=NV Access Limited(,|$)' - $process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru - Assert-InstallerExitCode -ProductName 'NVDA' -ExitCode $process.ExitCode - Remove-Item -LiteralPath $installerPath -Force -} -``` - -Keep `Install-GoogleChrome` behavior unchanged. Replace the Chocolatey package loop with `Install-Firefox`, `Install-Nvda`, and `Install-GoogleChrome` calls inside `Invoke-InstallSoftware`. - -- [ ] **Step 4: Add a failing version-output compatibility test** - -Append this test, then run it and verify RED because the version loop is not yet extracted: - -```powershell -Describe 'workflow version output contract' { - It 'emits the three existing VERSION keys' { - Mock Test-Path { $true } - Mock Get-Item { - [pscustomobject]@{ - VersionInfo = [pscustomobject]@{ - ProductVersion = '1.2.3' - FileVersion = '1.2.3.0' - } - } - } - $executables = [ordered]@{ - GOOGLECHROME = 'C:\Google\chrome.exe' - FIREFOX = 'C:\Mozilla Firefox\firefox.exe' - NVDA = 'C:\NVDA\nvda.exe' - } - - $output = @(Write-InstalledSoftwareVersions -SoftwareExecutables $executables) - - $output | Should -Contain 'VERSION_GOOGLECHROME=1.2.3' - $output | Should -Contain 'VERSION_FIREFOX=1.2.3' - $output | Should -Contain 'VERSION_NVDA=1.2.3' - @($output | Where-Object { $_ -like 'VERSION_*=*' }).Count | Should -Be 3 - } -} -``` - -- [ ] **Step 5: Extract version output and guard execution** - -Move the existing executable checks/version loop into this function: - -```powershell -function Write-InstalledSoftwareVersions { - param( - [Parameter(Mandatory = $true)] - [Collections.IDictionary]$SoftwareExecutables - ) - foreach ($software in $SoftwareExecutables.GetEnumerator()) { - if (-not (Test-Path $software.Value)) { - throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" - } - $versionInfo = (Get-Item $software.Value).VersionInfo - $version = if ($versionInfo.ProductVersion) { - $versionInfo.ProductVersion - } else { - $versionInfo.FileVersion - } - Write-Output "VERSION_$($software.Key)=$version" - } -} -``` - -In `Invoke-InstallSoftware`, preserve the existing Chrome wait/log-tail behavior and build the same ordered dictionary: - -```powershell -function Invoke-InstallSoftware { - Install-Firefox - Install-Nvda - Install-GoogleChrome - - Write-Output "$logPrefix Installed package versions:" - $chromeExecutable = Wait-GoogleChromeExecutable - if (-not $chromeExecutable) { - $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' - $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 ` - -ErrorAction SilentlyContinue) -join [Environment]::NewLine - throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" - } - $softwareExecutables = [ordered]@{ - GOOGLECHROME = $chromeExecutable - FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' - NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' - } - Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables -} -``` - -End the script with: - -```powershell -if (-not $SkipExecution) { - Invoke-InstallSoftware -} -``` - -Delete the Chocolatey bootstrap, `$chocoPath`, `$successfulExitCodes`, `Install-OrUpgradePackage`, and the `@('firefox', 'nvda')` loop. - -- [ ] **Step 6: Run the complete Pester file and verify GREEN** - -Run: - -```powershell -pwsh -NoProfile -Command "Invoke-Pester -Path './scripts/windows-a11y/tests/install-software.Tests.ps1' -Output Detailed" -``` - -Expected: all tests PASS, with no download or installer launched because all external boundaries are mocked. - -- [ ] **Step 7: Commit Task 2** - -```bash -git add scripts/windows-a11y/install-software.ps1 scripts/windows-a11y/tests/install-software.Tests.ps1 -git commit -m "feat: install official Firefox and NVDA releases" -m "Co-authored-by: Codex " -``` - -### Task 3: Documentation and end-to-end static verification - -**Files:** -- Modify: `docs/windows-a11y-aws-manual-setup.md:27-29` -- Verify: `.github/workflows/build-windows-a11y-ami.yml:94-101` -- Verify: `scripts/windows-a11y/verify-environment.ps1:30-33` - -**Interfaces:** -- Consumes: unchanged `VERSION_*` output and executable paths from Task 2. -- Produces: operator documentation that names the actual official outbound services. - -- [ ] **Step 1: Update the outbound-network documentation** - -Change the security-group explanation from “Windows Update, Chocolatey, and the SSM agent” to “Windows Update, Google, Mozilla, NV Access, and the SSM agent.” Do not change AWS setup instructions or variables. - -- [ ] **Step 2: Run all behavioral and syntax verification** - -Run the complete Pester file again, then parse the production script: - -```powershell -pwsh -NoProfile -Command "Invoke-Pester -Path './scripts/windows-a11y/tests/install-software.Tests.ps1' -Output Detailed" -pwsh -NoProfile -Command '$errors = $null; [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "./scripts/windows-a11y/install-software.ps1"), [ref]$null, [ref]$errors); if ($errors.Count) { $errors | Out-String | Write-Error; exit 1 }' -``` - -Expected: Pester reports zero failed tests and the parser command exits `0` without errors. - -- [ ] **Step 3: Verify scope and compatibility from the repository root** - -```bash -rg -n "choco|chocolatey|Install-OrUpgradePackage" scripts/windows-a11y/install-software.ps1 -rg -n "firefox-latest-ssl.*os=win64.*lang=zh-TW|download\.nvaccess\.org/releases/stable" scripts/windows-a11y/install-software.ps1 -rg -n "VERSION_GOOGLECHROME|VERSION_FIREFOX|VERSION_NVDA" scripts/windows-a11y/install-software.ps1 .github/workflows/build-windows-a11y-ami.yml -git diff --check -git status --short -``` - -Expected: the first command has no matches; the official endpoints and all three version keys are present; `git diff --check` exits `0`; only the intended script, test, and documentation files are changed. - -- [ ] **Step 4: Commit Task 3** - -```bash -git add docs/windows-a11y-aws-manual-setup.md -git commit -m "docs: describe official Windows software sources" -m "Co-authored-by: Codex " -``` - -- [ ] **Step 5: Perform final verification before reporting completion** - -Run the full Pester suite and PowerShell parser command fresh after all commits, then run `git status --short` and `git log -5 --oneline`. Report the exact test count, parser exit status, commits, and any limitation that the real installers were not executed outside an AMI build. Do not claim the live AMI build succeeds until the GitHub Actions workflow has actually built and verified an AMI. diff --git a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md b/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md deleted file mode 100644 index 53618a5..0000000 --- a/docs/superpowers/specs/2026-08-06-official-firefox-nvda-installers-design.md +++ /dev/null @@ -1,138 +0,0 @@ -# Official Firefox and NVDA Installers Design - -## Goal - -Change the Windows accessibility AMI provisioning flow so that every build -installs the current stable releases of Firefox and NVDA directly from their -official publishers instead of obtaining either package through Chocolatey. - -Firefox must be the Traditional Chinese (`zh-TW`) 64-bit stable release. NVDA -must be the current stable release; beta and release-candidate builds are not -eligible. - -## Current State - -`scripts/windows-a11y/install-software.ps1` currently bootstraps Chocolatey and -runs `choco upgrade` for the `firefox` and `nvda` packages. Chrome already uses -an official evergreen MSI and validates its Authenticode publisher. The AMI -workflow reads `VERSION_FIREFOX` and `VERSION_NVDA` from the script output and -writes those values into AMI tags. - -Although this environment is commonly described as the Windows 11 AMI, the -current workflow builds from AWS's Traditional Chinese Windows Server 2025 -base image. This change does not alter the base image or any other provisioning -behavior. - -## Selected Approach - -Use each publisher's stable, evergreen download surface at AMI build time: - -- Firefox: request Mozilla's official redirect endpoint with - `product=firefox-latest-ssl`, `os=win64`, and `lang=zh-TW`. -- NVDA: read the official `https://download.nvaccess.org/releases/stable/` - directory and select the installer whose filename matches - `nvda_.exe`. - -The NVDA filename rule permits numeric releases such as `2026.1` and -`2026.1.1`. It rejects filenames containing `alpha`, `beta`, `rc`, or any other -non-numeric version suffix. Resolution must fail if there is not exactly one -matching installer, rather than guessing among ambiguous results. - -This approach is preferred over parsing product marketing pages because the -evergreen endpoint and stable release directory are narrower, machine-oriented -publisher surfaces. Pinning URLs in the repository was rejected because it -would require manual updates and would not meet the requirement to install the -latest stable release on every AMI build. - -## Installation Flow - -The PowerShell script will define focused helpers for downloading with bounded -retries, validating Authenticode signatures, resolving the NVDA stable -installer, and running installers while checking their exit codes. The script -will retain a single orchestration entry point so its current SSM invocation -does not change. - -For each product: - -1. Create or reuse the existing temporary installer directory. -2. Resolve the official stable download URL. -3. Download the installer over HTTPS, trying at most three times and waiting - 15 seconds and then 30 seconds before the two retries. -4. Require an Authenticode status of `Valid` and an expected publisher: - `Mozilla Corporation` for Firefox and `NV Access Limited` for NVDA. -5. Run the installer silently and wait for completion: Firefox with `/S` and - NVDA with `--install-silent`. -6. Accept exit code `0` from both executable installers; otherwise fail the - provisioning command with the exit code and available diagnostic context. -7. Remove the downloaded installer after successful installation. - -Firefox will use Mozilla's supported silent-install switch. NVDA will use its -silent install command and install system-wide at the current 64-bit executable -location expected by `verify-environment.ps1`. The legacy x86 location remains -an explicit fallback for existing installations. - -There is no Chocolatey fallback. An unavailable official endpoint, malformed -stable listing, invalid signature, unexpected publisher, or failed installer -must stop the AMI build so an unverified or stale package is never baked into -the image. - -## Compatibility and Outputs - -The existing executable checks remain authoritative: - -- Firefox: `C:\Program Files\Mozilla Firefox\firefox.exe` -- NVDA primary: `C:\Program Files\NVDA\nvda.exe` -- NVDA legacy fallback: `C:\Program Files (x86)\NVDA\nvda.exe` - -After installation, the script will continue reading product/file version -metadata from those executables and emitting the existing lines: - -```text -VERSION_FIREFOX= -VERSION_NVDA= -``` - -Consequently, `.github/workflows/build-windows-a11y-ami.yml`, its output -parsing, AMI tags, and `verify-environment.ps1` require no interface changes. -The now-unused Chocolatey bootstrap and package-upgrade helper will be removed. - -## Error Handling and Logging - -Messages will identify the product, operation, attempt number, and terminal -failure without printing credentials or unrelated environment data. Retry is -limited to transient download failures. Signature failures, ambiguous NVDA -listings, and installer failures are deterministic and will fail immediately. - -Installer processes will be awaited before executable/version checks run, so -the existing verification cannot race an installation still in progress. - -## Testing - -Pester tests in -`scripts/windows-a11y/tests/install-software.Tests.ps1` will dot-source the -script without executing its orchestration entry point and cover the following -observable behavior: - -- the Firefox request uses Mozilla's official latest-stable endpoint with - `win64` and `zh-TW`; -- NVDA resolution accepts numeric stable filenames, including patch releases; -- NVDA resolution rejects beta and release-candidate filenames; -- zero or multiple eligible NVDA installers produce a clear failure; -- signature validation accepts only `Valid` signatures from the configured - publisher and rejects invalid or unexpected signatures; -- installer exit-code validation rejects failure codes; -- the public `VERSION_FIREFOX` and `VERSION_NVDA` output contract remains - unchanged. - -The implementation will follow a red-green cycle for each behavior. Final -verification will include the focused PowerShell tests, PowerShell syntax -validation, and inspection of the resulting diff for accidental workflow or -Chocolatey dependencies. - -## Out of Scope - -- Changing the Windows base AMI or AWS infrastructure. -- Changing Chrome installation behavior. -- Installing Firefox ESR, Beta, Developer Edition, or Nightly. -- Installing NVDA alpha, beta, or release-candidate builds. -- Adding a third-party package-manager fallback. From 86c36e8541a92e1263f0b94226c955d3ada4c54f Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 8 Aug 2026 00:12:37 +0800 Subject: [PATCH 10/11] fix: expose Windows Update failure details Log aggregate and per-update HRESULT diagnostics, and preserve failed SSM stdout and exit status in the AMI workflow. Co-authored-by: Codex --- .github/workflows/build-windows-a11y-ami.yml | 16 +----- scripts/windows-a11y/install-updates.ps1 | 37 ++++++++++++- scripts/windows-a11y/run-windows-updates.sh | 33 +++++++++++ .../tests/install-updates.Tests.ps1 | 55 +++++++++++++++++++ .../tests/run-windows-updates.Tests.ps1 | 25 +++++++++ 5 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 scripts/windows-a11y/run-windows-updates.sh create mode 100644 scripts/windows-a11y/tests/install-updates.Tests.ps1 create mode 100644 scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 diff --git a/.github/workflows/build-windows-a11y-ami.yml b/.github/workflows/build-windows-a11y-ami.yml index 68da3cd..cca7361 100644 --- a/.github/workflows/build-windows-a11y-ami.yml +++ b/.github/workflows/build-windows-a11y-ami.yml @@ -75,21 +75,7 @@ jobs: - name: Install Windows Updates (repeat until converged) run: | - for i in $(seq 1 5); do - OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/install-updates.ps1 3600) - echo "${OUTPUT}" - if echo "${OUTPUT}" | grep -q "No updates found."; then - echo "No further updates." - break - fi - if echo "${OUTPUT}" | grep -q "REBOOT_REQUIRED=true"; then - echo "Rebooting instance for updates (pass ${i})..." - aws ec2 reboot-instances --instance-ids "${{ steps.launch.outputs.instance_id }}" - sleep 30 - aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" - fi - done - echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV" + bash scripts/windows-a11y/run-windows-updates.sh "${{ steps.launch.outputs.instance_id }}" - name: Install/update Chrome, Firefox, NVDA id: software diff --git a/scripts/windows-a11y/install-updates.ps1 b/scripts/windows-a11y/install-updates.ps1 index 417ac3b..af1c728 100644 --- a/scripts/windows-a11y/install-updates.ps1 +++ b/scripts/windows-a11y/install-updates.ps1 @@ -4,6 +4,41 @@ param() $ErrorActionPreference = 'Stop' $logPrefix = '[install-updates]' +function ConvertTo-HResultHex { + param( + [Parameter(Mandatory)] + [long]$HResult + ) + + $unsignedValue = $HResult -band 0xffffffffL + return '0x{0:X8}' -f $unsignedValue +} + +function Write-InstallationDiagnostics { + param( + [Parameter(Mandatory)] + $InstallResult, + + [Parameter(Mandatory)] + $Updates, + + [string]$Prefix = '[install-updates]' + ) + + $aggregateHResult = ConvertTo-HResultHex -HResult $InstallResult.HResult + Write-Output "$Prefix Install result code: $($InstallResult.ResultCode); HRESULT: $aggregateHResult" + + for ($index = 0; $index -lt $Updates.Count; $index++) { + $update = $Updates[$index] + $updateResult = $InstallResult.GetUpdateResult($index) + $updateHResult = ConvertTo-HResultHex -HResult $updateResult.HResult + Write-Output ( + "$Prefix Update result: $($update.Title); code: $($updateResult.ResultCode); " + + "HRESULT: $updateHResult; reboot required: $($updateResult.RebootRequired)" + ) + } +} + Write-Output "$logPrefix Searching for updates..." $updateSession = New-Object -ComObject Microsoft.Update.Session $updateSearcher = $updateSession.CreateUpdateSearcher() @@ -40,7 +75,7 @@ $installer = $updateSession.CreateUpdateInstaller() $installer.Updates = $updatesToInstall $installResult = $installer.Install() -Write-Output "$logPrefix Install result code: $($installResult.ResultCode)" +Write-InstallationDiagnostics -InstallResult $installResult -Updates $updatesToInstall Write-Output "$logPrefix Reboot required: $($installResult.RebootRequired)" if ($installResult.ResultCode -ne 2 -and $installResult.ResultCode -ne 3) { diff --git a/scripts/windows-a11y/run-windows-updates.sh b/scripts/windows-a11y/run-windows-updates.sh new file mode 100644 index 0000000..d785d25 --- /dev/null +++ b/scripts/windows-a11y/run-windows-updates.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -uo pipefail + +INSTANCE_ID="${1:?Usage: run-windows-updates.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SSM_RUN_SCRIPT="${SSM_RUN_SCRIPT:-${SCRIPT_DIR}/ssm-run.sh}" +: "${GITHUB_ENV:?GITHUB_ENV must point to the GitHub Actions environment file}" + +for pass in $(seq 1 5); do + set +e + OUTPUT=$(bash "${SSM_RUN_SCRIPT}" "${INSTANCE_ID}" "${SCRIPT_DIR}/install-updates.ps1" 3600) + SSM_STATUS=$? + set -e + + printf '%s\n' "${OUTPUT}" + if (( SSM_STATUS != 0 )); then + exit "${SSM_STATUS}" + fi + + if grep -q "No updates found." <<< "${OUTPUT}"; then + echo "No further updates." + break + fi + + if grep -q "REBOOT_REQUIRED=true" <<< "${OUTPUT}"; then + echo "Rebooting instance for updates (pass ${pass})..." + aws ec2 reboot-instances --instance-ids "${INSTANCE_ID}" + sleep 30 + aws ec2 wait instance-status-ok --instance-ids "${INSTANCE_ID}" + fi +done + +echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "${GITHUB_ENV}" diff --git a/scripts/windows-a11y/tests/install-updates.Tests.ps1 b/scripts/windows-a11y/tests/install-updates.Tests.ps1 new file mode 100644 index 0000000..95f2b73 --- /dev/null +++ b/scripts/windows-a11y/tests/install-updates.Tests.ps1 @@ -0,0 +1,55 @@ +Describe 'Windows Update installation diagnostics' { + BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '..\install-updates.ps1' + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, + [ref]$tokens, + [ref]$errors + ) + $functionAsts = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @('ConvertTo-HResultHex', 'Write-InstallationDiagnostics') + }, $true) + + @($functionAsts).Count | Should -Be 2 + foreach ($functionAst in $functionAsts) { + Invoke-Expression $functionAst.Extent.Text + } + } + + It 'reports aggregate and per-update result codes and HRESULT values' { + $firstResult = [pscustomobject]@{ + ResultCode = 2 + HResult = 0 + RebootRequired = $false + } + $secondResult = [pscustomobject]@{ + ResultCode = 4 + HResult = -2145124329 + RebootRequired = $true + } + $installResult = [pscustomobject]@{ + ResultCode = 4 + HResult = -2145124329 + RebootRequired = $true + UpdateResults = @($firstResult, $secondResult) + } + $installResult | Add-Member -MemberType ScriptMethod -Name GetUpdateResult -Value { + param($index) + $this.UpdateResults[$index] + } + $updates = @( + [pscustomobject]@{ Title = 'Successful update' }, + [pscustomobject]@{ Title = 'Failed update' } + ) + + $output = @(Write-InstallationDiagnostics -InstallResult $installResult -Updates $updates) + + $output | Should -Contain '[install-updates] Install result code: 4; HRESULT: 0x80240017' + $output | Should -Contain '[install-updates] Update result: Successful update; code: 2; HRESULT: 0x00000000; reboot required: False' + $output | Should -Contain '[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017; reboot required: True' + } +} diff --git a/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 b/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 new file mode 100644 index 0000000..f4864bd --- /dev/null +++ b/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 @@ -0,0 +1,25 @@ +Describe 'Windows Update workflow runner' { + BeforeEach { + $script:runnerPath = Join-Path $PSScriptRoot '..\run-windows-updates.sh' + $script:ssmStub = Join-Path $TestDrive 'ssm-stub.sh' + $script:githubEnv = Join-Path $TestDrive 'github-env' + Set-Content -LiteralPath $script:ssmStub -Value @' +#!/usr/bin/env bash +echo "[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017" +exit 7 +'@ + New-Item -ItemType File -Path $script:githubEnv | Out-Null + } + + It 'prints captured SSM stdout and preserves a failed exit status' { + $env:SSM_RUN_SCRIPT = $script:ssmStub + $env:GITHUB_ENV = $script:githubEnv + + $output = @(& bash $script:runnerPath 'i-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 7 + $output | Should -Contain '[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017' + (Get-Content -LiteralPath $script:githubEnv -Raw) | Should -BeNullOrEmpty + } +} From 3da0629a43b5a30e073325547a9a68a5e8065973 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sun, 9 Aug 2026 22:24:46 +0800 Subject: [PATCH 11/11] fix: extend AMI availability polling Replace the fixed AWS image waiter with observable state polling that tolerates eventual consistency and waits up to thirty minutes. Co-authored-by: Codex --- .github/workflows/build-windows-a11y-ami.yml | 10 +- scripts/windows-a11y/create-ami.sh | 71 ++++++++++++ .../windows-a11y/tests/create-ami.Tests.ps1 | 106 ++++++++++++++++++ 3 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 scripts/windows-a11y/create-ami.sh create mode 100644 scripts/windows-a11y/tests/create-ami.Tests.ps1 diff --git a/.github/workflows/build-windows-a11y-ami.yml b/.github/workflows/build-windows-a11y-ami.yml index cca7361..a39edc7 100644 --- a/.github/workflows/build-windows-a11y-ami.yml +++ b/.github/workflows/build-windows-a11y-ami.yml @@ -134,13 +134,9 @@ jobs: id: create-image run: | AMI_NAME="windows-a11y-${{ github.event.inputs.ami_name }}" - IMAGE_ID=$(aws ec2 create-image \ - --instance-id "${{ steps.launch.outputs.instance_id }}" \ - --name "${AMI_NAME}" \ - --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ - --query 'ImageId' --output text) - aws ec2 wait image-available --image-ids "${IMAGE_ID}" - echo "ami_id=${IMAGE_ID}" >> "$GITHUB_OUTPUT" + bash scripts/windows-a11y/create-ami.sh \ + "${{ steps.launch.outputs.instance_id }}" \ + "${AMI_NAME}" - name: Tag AMI and snapshots run: | diff --git a/scripts/windows-a11y/create-ami.sh b/scripts/windows-a11y/create-ami.sh new file mode 100644 index 0000000..46ecc90 --- /dev/null +++ b/scripts/windows-a11y/create-ami.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTANCE_ID="${1:?Usage: create-ami.sh }" +AMI_NAME="${2:?Usage: create-ami.sh }" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must point to the GitHub Actions output file}" + +MAX_ATTEMPTS="${AMI_MAX_ATTEMPTS:-120}" +POLL_INTERVAL_SECONDS="${AMI_POLL_INTERVAL_SECONDS:-15}" +LOG_PREFIX="[create-ami]" + +IMAGE_ID=$(aws ec2 create-image \ + --instance-id "${INSTANCE_ID}" \ + --name "${AMI_NAME}" \ + --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ + --query 'ImageId' \ + --output text) + +echo "${LOG_PREFIX} Created AMI ${IMAGE_ID}." +echo "ami_id=${IMAGE_ID}" >> "${GITHUB_OUTPUT}" + +for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + set +e + IMAGE_JSON=$(aws ec2 describe-images --image-ids "${IMAGE_ID}" --output json 2>&1) + DESCRIBE_STATUS=$? + set -e + + if (( DESCRIBE_STATUS != 0 )); then + if grep -q 'InvalidAMIID.NotFound' <<< "${IMAGE_JSON}"; then + echo "${LOG_PREFIX} AMI ${IMAGE_ID} is not visible yet (attempt ${attempt} of ${MAX_ATTEMPTS})." + if (( attempt < MAX_ATTEMPTS )); then + sleep "${POLL_INTERVAL_SECONDS}" + fi + continue + fi + + echo "${IMAGE_JSON}" >&2 + exit "${DESCRIBE_STATUS}" + fi + + STATE=$(jq -r '.Images[0].State // "missing"' <<< "${IMAGE_JSON}") + STATE_REASON=$(jq -r '.Images[0].StateReason.Message // empty' <<< "${IMAGE_JSON}") + + if [[ -n "${STATE_REASON}" ]]; then + echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS}); reason: ${STATE_REASON}." + else + echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS})." + fi + + case "${STATE}" in + available) + exit 0 + ;; + failed) + echo "${LOG_PREFIX} AMI ${IMAGE_ID} creation failed." >&2 + exit 1 + ;; + pending) + if (( attempt < MAX_ATTEMPTS )); then + sleep "${POLL_INTERVAL_SECONDS}" + fi + ;; + *) + echo "${LOG_PREFIX} Unexpected AMI state '${STATE}' for ${IMAGE_ID}." >&2 + exit 1 + ;; + esac +done + +echo "${LOG_PREFIX} Timed out waiting for AMI ${IMAGE_ID} after ${MAX_ATTEMPTS} checks." >&2 +exit 1 diff --git a/scripts/windows-a11y/tests/create-ami.Tests.ps1 b/scripts/windows-a11y/tests/create-ami.Tests.ps1 new file mode 100644 index 0000000..91072b8 --- /dev/null +++ b/scripts/windows-a11y/tests/create-ami.Tests.ps1 @@ -0,0 +1,106 @@ +Describe 'AMI creation polling' { + BeforeEach { + $script:runnerPath = Join-Path $PSScriptRoot '..\create-ami.sh' + $script:binDirectory = Join-Path $TestDrive 'bin' + $script:stateFile = Join-Path $TestDrive 'describe-count' + $script:githubOutput = Join-Path $TestDrive 'github-output' + $script:originalPath = $env:PATH + + New-Item -ItemType Directory -Path $script:binDirectory -Force | Out-Null + Remove-Item -LiteralPath $script:stateFile -Force -ErrorAction SilentlyContinue + Set-Content -LiteralPath $script:githubOutput -Value '' -NoNewline -Force + + $awsStub = Join-Path $script:binDirectory 'aws' + Set-Content -LiteralPath $awsStub -Value @' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$1 $2" == "ec2 create-image" ]]; then + echo "ami-test123" + exit 0 +fi + +if [[ "$1 $2" == "ec2 describe-images" ]]; then + count=0 + if [[ -f "${AWS_STUB_STATE_FILE}" ]]; then + count=$(<"${AWS_STUB_STATE_FILE}") + fi + count=$((count + 1)) + printf '%s' "${count}" > "${AWS_STUB_STATE_FILE}" + + if [[ "${AWS_STUB_MODE}" == "not-found-then-available" && "${count}" == "1" ]]; then + echo "An error occurred (InvalidAMIID.NotFound) when calling DescribeImages" >&2 + exit 255 + elif [[ "${AWS_STUB_MODE}" == "failed" ]]; then + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"failed","StateReason":{"Message":"snapshot failed"}}]}' + elif (( count >= AWS_STUB_AVAILABLE_AFTER )); then + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"available"}]}' + else + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"pending"}]}' + fi + exit 0 +fi + +echo "unexpected aws invocation: $*" >&2 +exit 64 +'@ + & chmod +x $awsStub + + $env:PATH = "$script:binDirectory$([IO.Path]::PathSeparator)$script:originalPath" + $env:GITHUB_OUTPUT = $script:githubOutput + $env:AWS_STUB_STATE_FILE = $script:stateFile + $env:AMI_POLL_INTERVAL_SECONDS = '0' + Remove-Item Env:AMI_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + + AfterEach { + $env:PATH = $script:originalPath + Remove-Item Env:GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_STATE_FILE -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_MODE -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_AVAILABLE_AFTER -ErrorAction SilentlyContinue + Remove-Item Env:AMI_POLL_INTERVAL_SECONDS -ErrorAction SilentlyContinue + Remove-Item Env:AMI_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + + It 'continues polling beyond forty checks until the AMI is available' { + $env:AWS_STUB_MODE = 'available' + $env:AWS_STUB_AVAILABLE_AFTER = '41' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Contain '[create-ami] Created AMI ami-test123.' + $output | Should -Contain '[create-ami] AMI ami-test123 state: pending (attempt 40 of 120).' + $output | Should -Contain '[create-ami] AMI ami-test123 state: available (attempt 41 of 120).' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '41' + (Get-Content -LiteralPath $script:githubOutput -Raw).Trim() | + Should -BeExactly 'ami_id=ami-test123' + } + + It 'retries when a newly created AMI is not visible yet' { + $env:AWS_STUB_MODE = 'not-found-then-available' + $env:AWS_STUB_AVAILABLE_AFTER = '2' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Contain '[create-ami] AMI ami-test123 is not visible yet (attempt 1 of 120).' + $output | Should -Contain '[create-ami] AMI ami-test123 state: available (attempt 2 of 120).' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '2' + } + + It 'fails immediately and reports StateReason when the AMI enters failed state' { + $env:AWS_STUB_MODE = 'failed' + $env:AWS_STUB_AVAILABLE_AFTER = '999' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + $output | Should -Contain '[create-ami] AMI ami-test123 state: failed (attempt 1 of 120); reason: snapshot failed.' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '1' + } +}