test: cover target-argument parsing across the scripts (phase 4) (#29)

Part of #26. This layer has regressed more than any other - bare vs dashed
keys, 'all' expansion, and whether a bad key fails loudly or quietly selects
nothing.

42 tests over three guarantees:
  - Running bare shows usage and exits non-zero, for all ten target-taking
    scripts. Some of these used to mean 'do it to everything' when run with no
    args, which is how an unintended full backup or deploy happens.
  - An unknown key fails loudly. Exiting 0 having selected nothing is the
    dangerous outcome: a typo'd key in a scheduled task looks like a
    successful run that backed up nothing.
  - A leading dash is stripped before the lookup, so -myapp == myapp. Asserted
    via a dashed *unknown* key, so the error must name 'x' rather than '-x'.
Plus zkill's own resolution: bare key, dashed key, several keys, 'all' and
'-all' expanding to projects that have a ports.dev, edge/docker stacks skipped,
and -Port overriding the configured port.

Safety: the tests run the real scripts as child processes, so they are confined
to paths that exit before doing any work. Only zkill runs with a valid target,
because the fixture's dev ports (59990/59991) are deliberately unused and its
localRoots do not exist - it finds no listeners and kills nothing. zdeploy,
zbackup_ec2, zec2, zec2online, zrepair, zstop, zstart and zbackup are never
invoked with a real target; that is integration territory needing a disposable
server.

Each test runs against an isolated temp installation - the .ps1 files copied
beside a fixture zconfig.json - so nothing touches this repo, no real config is
read, and the suite passes on a machine that has never been configured. That
also makes it independent of the ZCONFIG seam in #27, so the PRs can merge in
any order.

Verified by mutation testing: removing zkill's all-expansion, dash tolerance
and no-args guard, making an unknown key exit 0, and letting underscore comment
keys leak in as projects each turn the suite red (2/1/2/16/4 tests). Both
mutated files confirmed restored byte-for-byte.
This commit is contained in:
kellymichels 2026-07-26 12:49:47 -05:00 committed by GitHub
parent d4980b3086
commit 035e93fcbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,217 @@
# Evomedia.net Token Savers - https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels - dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
# ArgumentParsing.Tests.ps1 - Pester 5 suite for how the scripts read their
# target arguments.
#
# Invoke-Pester .\tests
#
# This is the layer that has regressed most often: bare vs dashed keys, 'all'
# expansion, and whether a bad key fails loudly or silently selects nothing.
#
# SAFETY
# ------
# These tests run the real scripts as child processes, so they are restricted to
# code paths that exit BEFORE doing any work:
#
# * no arguments -> usage + non-zero exit
# * unknown project key -> error + non-zero exit (Get-ZProject exits first)
#
# Only zkill is exercised with a *valid* target, because with the fixture config
# it looks for listeners on deliberately unused high ports and finds none. The
# scripts that SSH, scp, deploy, or start servers (zdeploy, zbackup_ec2, zec2,
# zec2online, zrepair, zstop, zstart, zbackup) are NEVER invoked with a real
# target here - that is integration territory and needs a disposable server.
#
# Each test runs against an isolated temp installation: the .ps1 files copied to
# a temp dir alongside a fixture zconfig.json. Nothing touches this repo, and no
# real zconfig.json is read - so the suite is safe on a configured machine and
# passes on one that has never been configured.
BeforeAll {
$script:RepoRoot = Split-Path -Parent $PSScriptRoot
# Isolated installation: the scripts, plus a fixture config beside them.
$script:Install = Join-Path ([IO.Path]::GetTempPath()) ("zargs-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $script:Install -Force | Out-Null
Copy-Item (Join-Path $script:RepoRoot "*.ps1") $script:Install -Force
# Fake roots that do not exist and ports nothing listens on, so any script
# that somehow got past its guard would still find nothing to act on.
$fixture = @{
ec2 = @{ ip = "203.0.113.10"; user = "testuser"; pemKey = "C:\fixtures\test.pem"; stackRoot = "/home/testuser/stack" }
paths = @{
temp = (Join-Path $script:Install "temp")
backupsLocal = (Join-Path $script:Install "backups")
backupsEc2 = (Join-Path $script:Install "backups-ec2")
scriptsRoot = $script:Install
oneDriveBackups = ""
}
projects = [ordered]@{
_note = "comment key - must never be treated as a project"
pyapp = @{
label = "Fixture Python App"; kind = "python"
localRoot = (Join-Path $script:Install "nonexistent-pyapp")
startModule = "pyapp.main"
ports = @{ dev = 59990 }
remote = @{ path = "/home/testuser/stack/pyapp" }
}
viteapp = @{
label = "Fixture Vite Site"; kind = "vite"
localRoot = (Join-Path $script:Install "nonexistent-viteapp")
ports = @{ dev = 59991 }
remote = @{ path = "/home/testuser/stack/viteapp" }
}
edgeproxy = @{
label = "Fixture Edge"; kind = "edge"
localRoot = (Join-Path $script:Install "nonexistent-edge")
remote = @{ path = "/home/testuser/stack/edge" }
}
}
}
$fixture | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $script:Install "zconfig.json") -Encoding UTF8
# Run a script in a child process; capture merged output and exit code.
function Invoke-ZScript {
param([string]$Script, [string[]]$ScriptArgs = @())
$path = Join-Path $script:Install $Script
$out = & powershell -NoProfile -ExecutionPolicy Bypass -File $path @ScriptArgs 2>&1 | ForEach-Object { "$_" }
return [pscustomobject]@{
ExitCode = $LASTEXITCODE
Output = ($out -join "`n")
}
}
}
AfterAll {
Remove-Item -LiteralPath $script:Install -Recurse -Force -ErrorAction SilentlyContinue
}
# Every target-taking script must refuse to guess. Running bare used to mean
# "do it to everything" for some of these, which is how an unintended full
# backup / deploy happens.
Describe "no arguments shows usage and exits non-zero" {
It "<_> refuses to run bare" -ForEach @(
"zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1",
"zbackup_ec2.ps1", "zec2.ps1", "zec2online.ps1", "zrepair.ps1",
"zstop.ps1", "zbackup_and_sync.ps1"
) {
$r = Invoke-ZScript -Script $_
$r.ExitCode | Should -Not -Be 0 -Because "$_ run bare must not silently act on every project"
$r.Output | Should -Match "(?i)usage"
}
It "<_> lists the available project keys in its usage" -ForEach @(
"zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1"
) {
$r = Invoke-ZScript -Script $_
$r.Output | Should -Match "pyapp"
$r.Output | Should -Match "viteapp"
}
It "usage never advertises the underscore comment key" -ForEach @(
"zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1"
) {
(Invoke-ZScript -Script $_).Output | Should -Not -Match "_note"
}
}
# An unknown key must fail loudly. Silently selecting nothing and exiting 0 is
# the dangerous outcome: a typo'd key in a scheduled task would look like a
# successful run that backed up nothing.
Describe "unknown project key fails loudly" {
It "<_> rejects a key that is not in the config" -ForEach @(
"zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1", "zbackup.ps1",
"zbackup_ec2.ps1", "zec2.ps1", "zec2online.ps1", "zrepair.ps1", "zstop.ps1"
) {
$r = Invoke-ZScript -Script $_ -ScriptArgs @("definitelynotaproject")
$r.ExitCode | Should -Not -Be 0 -Because "$_ must not treat an unknown key as success"
$r.Output | Should -Match "(?i)unknown project key|not.*found|available"
}
It "<_> names the offending key and the valid ones" -ForEach @(
"zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1"
) {
$r = Invoke-ZScript -Script $_ -ScriptArgs @("definitelynotaproject")
$r.Output | Should -Match "definitelynotaproject"
$r.Output | Should -Match "pyapp"
}
It "the underscore comment key is not addressable as a project" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("_note")
$r.ExitCode | Should -Not -Be 0
}
}
# zdeploy -myapp == zdeploy myapp. The dash is tolerated everywhere for anyone
# with switch-style muscle memory; it must be stripped before the lookup, not
# passed through into the key.
Describe "leading-dash tolerance" {
It "<_> strips a leading dash before resolving the key" -ForEach @(
"zstart.ps1", "ZKillOnly.ps1", "zdeploy.ps1"
) {
# A dashed *unknown* key proves the strip happened: the error must name
# 'definitelynotaproject', not '-definitelynotaproject'. Using an unknown
# key keeps this safe - it exits before doing any work.
$r = Invoke-ZScript -Script $_ -ScriptArgs @("-definitelynotaproject")
$r.Output | Should -Match "'definitelynotaproject'"
}
}
# zkill is the one script safe to run for real here: the fixture's dev ports are
# deliberately unused, so it finds no listeners and kills nothing.
Describe "zkill target resolution (safe: fixture ports are unused)" {
It "accepts a bare project key" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp")
$r.ExitCode | Should -Be 0
$r.Output | Should -Match "Fixture Python App"
$r.Output | Should -Match "59990"
}
It "accepts the same key with a leading dash and behaves identically" {
$bare = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp")
$dashed = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("-pyapp")
$dashed.ExitCode | Should -Be $bare.ExitCode
$dashed.Output | Should -Match "Fixture Python App"
}
It "accepts several keys at once" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp", "viteapp")
$r.ExitCode | Should -Be 0
$r.Output | Should -Match "Fixture Python App"
$r.Output | Should -Match "Fixture Vite Site"
}
It "'all' expands to every project that has a dev port" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("all")
$r.ExitCode | Should -Be 0
$r.Output | Should -Match "Fixture Python App"
$r.Output | Should -Match "Fixture Vite Site"
}
It "'all' skips projects with no dev port (edge/docker stacks)" {
# edgeproxy has no ports.dev - it has no local dev server to stop.
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("all")
$r.Output | Should -Not -Match "Fixture Edge"
}
It "'-all' works the same as 'all'" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("-all")
$r.ExitCode | Should -Be 0
$r.Output | Should -Match "Fixture Python App"
}
It "reports the port it inspected rather than failing on an idle port" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("viteapp")
$r.ExitCode | Should -Be 0
$r.Output | Should -Match "59991"
}
It "-Port overrides the configured dev port" {
$r = Invoke-ZScript -Script "ZKillOnly.ps1" -ScriptArgs @("pyapp", "-Port", "59999")
$r.ExitCode | Should -Be 0
$r.Output | Should -Match "59999"
$r.Output | Should -Not -Match "59990"
}
}