DEV Community

TiltedLunar123
TiltedLunar123

Posted on

the same test passed in CI and failed on my laptop, and the code was identical

i maintain a powershell script that strips a pile of telemetry and bloat services off a fresh windows install. the feature people actually care about isn't the cleanup, it's the undo. every registry value it touches gets written to a json file before the change, and -Undo somefile.json walks the list backward and puts the old values back. if that rollback can't be trusted, nobody should run the tool at all, so the undo module has the most tests in the repo.

which is why this bug bothered me more than it should have.

i had a test for the dullest possible case. you call -Undo and point it at a file that isn't there. the function shouldn't crash. it should warn, and return $false, because the main script reads that boolean to decide whether to print "rollback complete" or "rollback failed." return the wrong thing and the script lies to the user about whether their machine actually got restored.

the test was green in CI. red on my laptop. same commit. same pester version. same single line of code under test.

i wasted a good chunk of an evening assuming it was an environment thing. wrong pwsh version, some stale module cached in memory, something like that. it wasn't any of that.

here's the function, roughly how it looked:

function Restore-FromUndoFile {
    param([string]$Path)

    if (-not (Test-Path $Path)) {
        Write-Error "Undo file not found: $Path"
        return $false
    }
    # ... walk the json, restore each value ...
}
Enter fullscreen mode Exit fullscreen mode

looks fine. it returns $false right there in the missing-file branch. except Write-Error writes a non-terminating error, and what a non-terminating error actually does depends on $ErrorActionPreference, which this function never sets. it inherits whatever the caller has.

my interactive shell runs with the default, Continue. so Write-Error prints a red line and execution keeps going, hits the return $false, done. that's the behavior i wanted.

the pester run in CI sets $ErrorActionPreference = 'Stop'. under Stop, that same Write-Error becomes terminating. the function never reaches the return. it throws.

once i actually suspected the preference, confirming it took two lines. i set $ErrorActionPreference = 'Stop' in my own shell and reran the single test. it threw, the CI behavior. set it back to Continue, reran, it returned false. that was the entire mystery. one global i never touched, set to one value on my machine and a different value in the CI yaml.

so the two environments were running genuinely different paths through the same lines. and the test had quietly been written to expect a throw, which is why CI (Stop, throws) was green and my laptop (Continue, returns false) was red. the suite wasn't catching a regression. it was just agreeing with whichever preference happened to be set. it was never agreeing with itself.

the real contract was supposed to be simple: return a boolean, never throw, because the caller branches on the boolean. the function had two ways to signal one failure and they disagreed depending on a global i wasn't even thinking about.

the fix was small and a little embarrassing:

    if (-not (Test-Path $Path)) {
        Write-Warning "Undo file not found: $Path"
        return $false
    }
Enter fullscreen mode Exit fullscreen mode

Write-Warning is never promoted to terminating by $ErrorActionPreference. it prints, execution continues, the function returns $false every time, in every shell. then i rewrote the test to assert the real contract instead of the accidental one: calling it on a missing path should not throw, and should return $false. green in CI, green locally, and now for the same reason.

the thing i actually took away: in powershell, return $false sitting right after a Write-Error is not a reliable way to report failure. you've written a function whose return value secretly depends on the caller's $ErrorActionPreference. if you want a boolean contract, don't emit an error record on the failing path. emit a warning, or throw on purpose and make the caller catch it. pick one. mixing "i return a bool" with "i also write an error" gives you a function that behaves one way in a strict shell and another way in a loose one, and CI is almost always the strict shell.

what's still not great: i only found this because i happened to run the suite locally that day. CI on its own would have stayed green forever, and i'd have shipped an undo path that throws an unhandled exception on exactly the input it exists to handle gracefully, a missing file. the durable fix isn't this one line. it's that my pester setup should pin $ErrorActionPreference to the same value CI uses so the two can't drift apart again. i haven't done that yet. it's next on the list, and it probably should have been there from the start.

if you write powershell and you have tests, go check what $ErrorActionPreference is during your CI run versus your normal shell. if they differ, some of your green checks are telling you something that isn't true, and you won't know which ones until one of them flips.

repo, if you want to read the actual module: https://github.com/TiltedLunar123/Ultimate-Windows-System-Optimizer

Top comments (0)