installer/tests
The installer’s scripted checks live here: the size gate, the release version,
the WinHTTP test and the GUI tests. Rust unit tests do not: they sit beside
the module they test (src/gamedir/tests.rs, src/tree/tests.rs), and this
directory takes Rust tests only when they drive the public API across several
modules, named after the scenario they cover.
Run the lot:
pwsh -File tests/Run-AllTests.ps1 # everything
pwsh -File tests/Run-AllTests.ps1 -SkipUi # headless: logic + size gate only
What is here
| Path | Kind | Needs a desktop? | In CI? |
|---|---|---|---|
cargo test | AUTOMATED TEST — the unit tests in src/, plus applying_a_selection.rs and restoring_a_game_folder.rs: the engine and a restore over a scratch game folder, sharing common/mod.rs | no | yes |
cargo test -- --ignored | MANUAL CHECK — the zip reader against a corpus of real packages (src/zip/fixtures/README.md) | no | no |
Test-ReleaseVersion.ps1 | AUTOMATED TEST — a build carries its v/X.Y.Z tag’s version | no | yes |
Test-BinarySize.ps1 | AUTOMATED TEST — the 700 kB release gate, and no DLL Windows does not ship | no | yes |
Test-HttpClient.ps1 | AUTOMATED TEST — WinHTTP vs a live server | no | yes |
Test-CrashReport.ps1 | AUTOMATED TEST — a panic leaves a usable report | no | yes |
Test-Redirects.ps1 | AUTOMATED TEST — a token never leaves its origin | no | yes |
ui/Test-UiLayout.ps1 | AUTOMATED TEST — walks the wizard, scenes 1-3 | yes | no |
ui/Get-UiScreenshot.ps1 | AGENT / DEVELOPER AID — captures PNGs of scenes 1-7 | yes | no |
ui/Win32Ui.psm1 | SUPPORT MODULE — shared user32.dll helpers | n/a | n/a |
Test-PublicEndpoint.ps1 | MANUAL CHECK — the real server over HTTPS | no | no |
ui/Test-WizardDownload.ps1 | AUTOMATED TEST — scenes 1-7, live catalogue, real downloads | yes | no |
Run-AllTests.ps1 | runner for all of the above, plus fmt + clippy | optional | no |
Run-AllTests.ps1 also runs cargo fmt --check and cargo clippy under
-D warnings, matching the CI installer job. The installer is excluded from
the Cargo workspace, so the root server CI job’s fmt/clippy never touch it —
without these it would go unlinted.
CI skips the GUI tests (they drive real dialogs and need an interactive
desktop session) and Test-PublicEndpoint.ps1 (it depends on a machine
someone else runs).
Test-PublicEndpoint.ps1 earns its place despite not being a gate: every
other test talks plain HTTP to loopback, so it is the ONLY one that takes the
WINHTTP_FLAG_SECURE branch and exercises the Schannel handshake. That path
is the justification for shipping no TLS crate at all, so it needs something
that can actually fail. Run it when the deployment changes.
Three kinds, and each script states which it is in its own .SYNOPSIS:
| Kind | Meaning | Gate CI on it? |
|---|---|---|
| AUTOMATED TEST | Asserts, hermetic, exits non-zero on failure. | Yes |
| AGENT / DEVELOPER AID | Produces artifacts for a person or an agent to look at. Asserts nothing, always exits 0. | No |
| MANUAL CHECK | Asserts, but against something OUTSIDE this repo. | No — a red result often says nothing about the code |
Anything that downloads is given a throwaway cache with --cache. Without
it a run would read back whatever a previous run left in the per-user cache —
so the suite would pass or fail depending on history — and would quietly fill
a developer’s profile with fixture packages. Get-UiScreenshot.ps1 takes the
same flag, for the same reason: a scene 6 with nothing to download is not
worth a screenshot.
Test-HttpClient.ps1 builds and runs a real pacenotes-server: WinHTTP
cannot be unit tested, so the only honest test is against the server the
installer actually talks to. It drives the binary’s --selftest-http and
--selftest-report flags, which write a report to a file rather than opening
a window — which is why it runs in CI while the GUI suite does not. Besides
the download matrix it covers the two endpoints that are not downloads:
GET /whoami, the only way to validate an access key (and the /packages
behaviour that is the reason — a bad key gets a 200 and the public listing,
never a 401), and POST /report/conflict, asserting on the JSONL line the
server stored rather than on the installer’s own report.
cargo test ignores ui/ and the
.ps1 files, so the two suites coexist in one directory without interfering.
How tests here are written
Two rules, both learned by writing worse versions first.
1. No arbitrary inputs
A test input must stand for a real situation the user can be in. The
game-folder field exists to tell “this is your Richard Burns Rally install”
from “this is some other folder”, so its fixtures are exactly that: a
directory containing RichardBurnsRally_SSE.exe, and one that is not. Handing
it $env:TEMP would assert nothing about the thing the field is for — and
indeed an earlier version of this suite did that, and happily passed while the
code accepted any directory that existed.
Likewise, the “Install proceeds once all three categories have a pick” path
was deliberately not asserted while those lists were empty: injecting
placeholder rows would only have proved that a listbox can hold a string. It
is asserted now, in ui/Test-WizardDownload.ps1, because scene 5 fills the
lists from a real catalogue served by a real server — and the rows it checks
for are counted from that catalogue rather than written out a second time.
2. Expected text comes from the resource, not from a copy
ui/Test-UiLayout.ps1 reads res/installer.rc via Get-ResourceStrings and
asserts each control shows exactly what the resource defines for the language
under test. It runs the wizard against a deliberately dead server
(--server http://127.0.0.1:1) so scene 2 takes its documented OFFLINE path
and the walk is deterministic; the online paths belong to
Test-HttpClient.ps1. Hardcoding the strings would only prove that two hand-written
copies agree, and would keep passing if a caption were bound to the wrong id or
a translation were never added.
The one deliberate exception is the language picker, which is shown before a
language has been chosen and therefore renders in the OS UI language. It cannot
honor a choice the user has not made, so its captions are asserted to be a
shipped string for their id in some language — which still catches the <id>
fallback that i18n::load_string emits for a missing or mis-bound id.
Traps these scripts encode
Do not “simplify” these away; each cost real debugging time and the workaround is commented at its call site.
-
GetWindowTextdoes not cross process boundaries. For a window owned by another process it returns only the cached window title. Static and Button captions live in that cache and read back fine, but Edit and ListBox contents come back empty — silently turning assertions into false passes.Get-CtrlTextusesWM_GETTEXT, which USER32 does marshal. -
TCM_SETCURSELdoes not notify the parent. Setting a tab selection that way skips the app’sTCN_SELCHANGEhandler, so the test proves nothing. A syntheticWM_NOTIFYis not an option either — it needs anNMHDRin the target process’s address space.Select-Tabsends arrow keys, so the tab control raises a genuineTCN_SELCHANGE. -
PowerShell
-liketreats[...]as a character class. Log lines are prefixed with[Game Name], so-like "*[BeamNG]*"matches almost any text. Use.Contains()for those. -
PowerShell variable names are case-insensitive. A local
$tabsilently clobbers a parameter$Tab. Helper parameters here are named distinctly. -
SetForegroundWindowis refused to a process that is not already in the foreground, so a screen grab silently captures whatever IS on top — this produced screenshots of unrelated applications.Get-UiScreenshot.ps1usesPrintWindowwithPW_RENDERFULLCONTENT, which asks the window to render itself and ignores z-order. -
Wizard page controls are GRANDchildren of the property sheet. The sheet owns one child dialog per page and only the active one is visible, so
GetDlgItemon the sheet finds none of them.Get-ActivePagefinds the visible page first; a test that skipped it would conclude every control was missing. -
A MessageBox’s OK button is not reliably control id 1. On this app’s own warning boxes it came back as id 2, so the posted
WM_COMMANDIDOK thatDismiss-Modalused to send closed nothing. A box left standing is particularly nasty here: the page underneath is disabled, yet its child controls still answer messages sent straight to them, so the symptom is a click that “does nothing” several steps later.Dismiss-Modalnow clicks whichever button the box actually has. -
Tree and list selections must be made by CLICK, not by key. Sending
VK_SPACEto a checkbox tree from outside the process toggled nothing, andVK_HOME/VK_DOWNmoved a listbox selection without the parent ever gettingLBN_SELCHANGE— so the app never learned what was picked. AWM_LBUTTONDOWNon the row is handled from the message alone and raises the real notification.Invoke-TreeCheckRootsandSelect-ListItemtake the geometry from the control (item height, indent) rather than assuming it.
Requirements
- The GUI tests are Windows-only and need an interactive desktop
session; they will not run on a headless agent.
cargo testand the size gate have no such restriction. - Build before running the GUI tests: they default to
target/x86_64-pc-windows-msvc/release/pacenotes-installer.exe. Pass-ExePathto point elsewhere. - Scripts kill the installer in a
finallyblock and delete their fixtures, so a failed run leaves no stray window or temp folder behind.