Testing matrix — homelab-dr-blueprint¶
This repo is mostly documentation (a DR/orchestration blueprint) plus 11 small PowerShell scripts, 3 Docker Compose files, and 4 Kubernetes learning manifests. There is no C#, JS/TS, or Python anywhere in this repo — the house "4-layer" standard (mocked unit + mutation + churn + automation/E2E) is adapted here, honestly, to what a docs-and-PowerShell blueprint actually is: Pester for every script + a markdown link-checker + manifest sanity checks, which is exactly the substitution the house build process calls for when a repo is docs-first. This document is that adaptation, made explicit so nobody mistakes "adapted" for "skipped."
| Pillar | Tool | Status here | Gate |
|---|---|---|---|
| Unit | Pester 5+ | Implemented (tests/Scripts.Tests.ps1) |
test CI job |
| Coverage | Pester code coverage | Implemented, floor 25% (see below) | fails build under threshold |
| Mutation | Stryker | N/A for PowerShell (see below) | substituted by coverage + churn |
| Churn / flake | Pester 5x loop | Implemented (churn CI job) |
fails if any run differs |
| Automation / E2E | Real -WhatIf script invocations + child-process runs |
Implemented (tests/Hermetic.Tests.ps1, tests/ChildProcess.Tests.ps1) |
runs in CI |
| Docs test layer | Markdown link-checker | Implemented (tests/MarkdownLinks.Tests.ps1) |
runs in CI |
| Manifest sanity | YAML/compose structural checks | Implemented (tests/Manifests.Tests.ps1) |
runs in CI, docker-live checks -Skip without a daemon |
Why this repo's shape forces adaptation¶
Every one of the 11 scripts is infra glue: it either needs a running Docker daemon (not just the CLI), Administrator elevation, a real WSL2 distro/SSH target, or actual multi-machine hardware (Swarm join, k3s server+agent, cross-node failover) to do its real work. None of that is available, or safe to invoke unattended, in a CI runner or a dev shell. The brief's own escape hatch — "if structurally impossible (heredocs, network-only branches), set an honest lower floor and justify it" — is exactly this repo's situation, script by script:
| Script | What blocks full exercise |
|---|---|
Deploy-ComposeStack.ps1 |
docker compose config runs unconditionally against a live daemon, even under -WhatIf |
Backup-PostgresWarmStandby.ps1 |
needs the docker CLI (checked hermetically), but the actual dump needs a running container |
Test-PostgresRestore.ps1 |
same: docker CLI check is hermetic, the restore itself needs a live daemon |
Init-Swarm.ps1 |
docker info against a live daemon; safe without one, but does nothing interesting |
Deploy-SwarmStack.ps1 |
requires this node to already be a Swarm manager |
Set-HomelabHostsAliases.ps1 |
requires Administrator; real write path is C:\Windows\...\hosts |
Install-K3sServer.ps1, Join-K3sAgent.ps1, Get-K3sKubeconfig.ps1, Teardown-K3s.ps1 |
need a real Linux node (WSL2 distro or SSH-reachable VM) — explicitly marked # REQUIRES REAL HARDWARE in-script |
Test-NodeResolution.ps1 |
not blocked, but calls PowerShell's exit unconditionally, so it can only be tested as a child process (see below) |
Promote-PostgresStandby.ps1 |
orchestrates all of the above; happens to be the one script whose -WhatIf path is fully hermetic (see below) |
Unit (Pester 5+)¶
tests/Scripts.Tests.ps1 is hermetic — no Docker, no elevation, no network.
It asserts, for every shipped .ps1 (11 scripts across foundation/,
compose/scripts/, compose/swarm/, kubernetes/scripts/ — tests/*.ps1
itself is excluded from these structural checks, though it IS covered by the
secret-literal scan below):
- parses with zero AST errors
(
[System.Management.Automation.Language.Parser]::ParseFile); - no secret-shaped literal anywhere in the repo — council-token,
Bearer <token>, AWSAKIA...,sk-..., GitHubgh[a-z]_..., Slackxox[a-z]-..., PEM private-key headers — scanned across every.ps1,.md,.yml/.yaml,.sql,.env,.env.example, and.jsonfile, includingarchive/(currently empty; the check still runs so it fires the moment something lands there); - every mutating script (12 of them) declares
[CmdletBinding(SupportsShouldProcess = $true)], so-WhatIfis always available; - every script that needs real multi-machine hardware carries an in-script
# REQUIRES REAL HARDWAREbanner (this caught a real doc gap — see "Bugs this suite caught" below); - every script has comment-based help with a
.SYNOPSISblock.
Run locally:
Invoke-Pester -CI -Path tests/
Coverage floor: 25%, not the house 70%¶
Pester's -CodeCoverage only credits lines that actually execute in the
same PowerShell runspace as Pester itself (via the call operator &, not a
child process). Given the table above, only a subset of each script's lines
are reachable that way:
Set-PrimaryMarker.ps1(SetPrimary/ClearPrimarybranches — theGetbranch always callsexitand is child-process-only),Promote-PostgresStandby.ps1(fully hermetic under-WhatIf— see below),Install-K3sServer.ps1,Join-K3sAgent.ps1,Get-K3sKubeconfig.ps1,Teardown-K3s.ps1(all-WhatIf-hermetic, no real hardware touched),Init-Swarm.ps1,Backup-PostgresWarmStandby.ps1,Test-PostgresRestore.ps1(docker-CLI-gated,-WhatIfcovers the pre-flight/logging lines, not the actual dump/restore),Deploy-SwarmStack.ps1(covers the manager-check guard clause), andSet-HomelabHostsAliases.ps1(-WhatIf, redirected to a scratch hosts file) are all included in the coverage run (tests/Invoke-CI.ps1's$cfg.CodeCoverage.Pathlist).Deploy-ComposeStack.ps1is excluded from the coverage-credited set entirely (its-WhatIfpath still callsdocker compose configagainst a live daemon, which isn't available in every dev/CI environment) — it is still parse-checked and structurally checked, just not coverage-instrumented.Test-NodeResolution.ps1is excluded from the coverage-credited set on purpose: it always callsexit, so it is tested via a real child process (tests/ChildProcess.Tests.ps1), and a child process's execution is invisible to Pester's in-process coverage tracer. It would only ever show as 0% if included, which would just be noise.
Measured coverage on the credited set, at time of writing: 34.3%
(131 tests, all passing, run 3x locally with identical results — no flake
observed). The floor in tests/Invoke-CI.ps1 is set to 25%, giving margin
without pretending an infra-glue script can hit 70% without touching the
infrastructure it glues.
pwsh -File tests/Invoke-CI.ps1 # enforces the 25% floor
pwsh -File tests/Invoke-CI.ps1 -MinCoverage 0 # churn loop mode, no floor
Mutation — Stryker is N/A for PowerShell¶
Stryker has no PowerShell mutator. Stryker.NET (C#), StrykerJS (JS/TS), and Stryker4s (Scala) are the only supported targets, and this repo has none of those languages. Mutation coverage is substituted by the Pester line/branch coverage above plus the churn (flake) job below. Rule for any future non-PowerShell code added to this repo: it must run real Stryker.NET/StrykerJS with a real mutation-score threshold — this "N/A" applies only to the PowerShell-and-docs shape this repo has today.
Churn / flake detection¶
The churn CI job runs the full Pester suite 5 times and fails if any
run's pass/fail/skip counts differ from the first. This matters more than
usual here because several tests touch real (if fast-failing) infrastructure:
Test-Connection to a deliberately-nonexistent alias in the
Promote-PostgresStandby.ps1 test, DNS resolution in the
Test-NodeResolution.ps1 child-process test, and temp-directory/GUID-based
fixtures throughout. Run locally 3x with identical 131/131 pass results before
this floor was set.
Automation / E2E — real invocations, not just parsing¶
Two files carry this pillar:
tests/Hermetic.Tests.ps1invokes scripts in-process via the call operator (& $ScriptPath ..., never dot-sourced) with-WhatIfor a read-only-Action, using temp-directory fixtures so nothing touches real state (a scratch hosts file, a scratch marker path, a scratch backup dir with a fakeLATEST.txt). This is what caught a real bug: see below.tests/ChildProcess.Tests.ps1runsTest-NodeResolution.ps1andSet-PrimaryMarker.ps1 -Action Getas an actual childpwsh/powershellprocess, because both call PowerShell'sexitstatement unconditionally — callingexitinside a script invoked in-process (even via&, not just dot-sourcing) risks tearing down the whole Pester host. The child-process pattern is 100% safe at the cost of no coverage credit (see above).
Bugs this suite caught¶
Install-K3sServer.ps1line 88 had$((if($Transport -eq 'wsl'){...}))— a double-wrapped subexpression that PowerShell parses as trying to invokeifas a command, throwingThe term 'if' is not recognized...on every call, including-WhatIf. Parse-only checks (AST parsing) never caught this because the syntax is valid PowerShell; only an actual invocation surfaces it. Fixed to a single$(...). This is the concrete argument for "real-WhatIfinvocation, not just parsing" as a test pillar.- Documentation gap:
kubernetes/README.md's "Validation status" section claims every k3s script carries a# REQUIRES REAL HARDWAREbanner;Get-K3sKubeconfig.ps1andTeardown-K3s.ps1didn't. Added, and the Scripts.Tests.ps1 structural check now enforces it going forward.
Docs test layer: markdown link-checker¶
tests/MarkdownLinks.Tests.ps1 is this repo's substitute for
"automation/E2E" on the documentation itself, since the primary deliverable
here is documentation. It extracts every markdown-style link (bracketed link
text immediately followed by a parenthesized target) from every .md file and
asserts the relative target resolves to a real file on disk
(external http(s):///mailto: links and same-file #anchor links are
intentionally skipped — no network calls in a hermetic test). It also rejects
bare TODO/TBD/FIXME/... placeholder link targets.
Manifest / compose sanity¶
tests/Manifests.Tests.ps1 checks, without Docker: no tab characters in any
YAML file (YAML forbids them), the claude-mem stack declares its three
expected services and a Postgres healthcheck, the sample-daemon stack and the
Swarm stack are provably stateless (no volumes:, no known stateful image
name), and every k3s learning manifest carries a visible not-for-prod/learning
marker. A real docker compose config validation runs only when a Docker
daemon is actually reachable (checked via docker info); otherwise it
-Skips with a documented reason rather than failing the build in an
environment with no live Docker Desktop.
Local quickstart¶
# full suite, no coverage floor
Invoke-Pester -CI -Path tests/
# with the coverage gate exactly as CI runs it
pwsh -File tests/Invoke-CI.ps1
# churn (what the CI churn job does, once)
pwsh -File tests/Invoke-CI.ps1 -MinCoverage 0