ci(test): run the test job on windows too - #272
Conversation
Extend the test job to the same os matrix the build job already uses, so the win32 branches (pathsHandlersWin32, atomic-write rename semantics, symlink cases that skipIf on win32) run for real instead of only in their skipped form. Refs #267
The first real run of the test job on windows-latest found a handful of tests that fail purely because of platform differences the tests never accounted for, not bugs in the code they cover: - accountLoginFlow.test.ts read the handler source without normalizing line endings, so its "\n"-based slice landed in the wrong place once git checked the file out with CRLF. - accountStore.test.ts, configHandlers.test.ts, modsHandlers.test.ts and permissions.test.ts all read a POSIX mode bit (0o600, 0o755, and friends) back off a real file after chmod. NTFS has no such bits; chmod there only toggles the read-only attribute. These now skipIf(win32), the same pattern backgroundHandlers.test.ts and pathsHandlers.test.ts already use for symlink-only cases. - pathsHandlers.test.ts had one RUN_INSTALLER test whose own header comment already documented it as covering "the not-windows arm, real unstubbed behavior on the Linux host these tests run on." On an actual windows host that arm can't fire, so it now skips there too. A separate, larger set of gameHandlers.test.ts and extraction.test.ts failures is left as is; those need a closer look before deciding whether they're more test gaps or something the launcher itself gets wrong on Windows.
None of them turned out to be a Windows bug in the launcher. The twelve EXECUTE_GAME failures all came from one fixture assumption: every test in gameHandlers.test.ts writes a game binary called "Vintagestory", and buildGameLaunchPlan only ever looks for "Vintagestory.exe" on Windows. So the folder held no game, the handler answered no-executable, and every outcome those tests were written for went unreached, adoption included. Renaming the file by platform in one place restores all twelve, and the two that looked like they diverged into session adoption with mismatched uids were the same cascade one level further down: with no launch there is no session write, so nothing was there to adopt. Reproduced on Linux by pointing the same helper at a name the launcher does not know, which fails all twelve with the exact Windows messages, mismatched uids and all. Three tests in the same file cannot work on Windows whatever the binary is called. Two make a write fail by taking write permission off the installation folder and one makes a folder unlistable with chmod 0o000; NTFS has no such mode bits, so the write lands and the folder lists. They skip there with the reason on them. The three CHANGE_PERMS failures share one cause too: the handler returns false on anything that is not Linux before it looks at its arguments, which is right, since POSIX mode bits are the only thing it has to apply. So the two validation tests get no throw to catch and the worker test waits for a worker that is never started. Also reproduced on Linux, by making that early return fire here. Of the two in extraction.test.ts, one asked the filesystem whether "vintagestory" exists to prove the wrapping folder was flattened away, which on a case-insensitive filesystem answers about the "Vintagestory" file sitting next to it. The full listing on the line above already says it, and says it better, since an extra folder could not hide from it either; breaking the flattening still fails the test with that line gone. The other spends two 7-Zip processes on a 2000 file archive and runs past the five second default on a Windows runner. Nothing in the coalescing it covers is platform-specific, and #274 replaces the test with a yauzl one that spawns nothing, so it skips on Windows for now.
…xception With the fixtures naming the binary Windows actually looks for, the Windows job got far enough to spawn it, and nine tests then failed on a raw "spawn UNKNOWN" coming out of the handler itself. child_process.spawn only reports ENOENT, EACCES, EAGAIN, EMFILE and ENFILE through an "error" event. Everything else it throws where it stands, and Windows answers a file that is not a valid executable with UNKNOWN, which is none of those five. Both spawns in this file were written for the event alone, so the throw went straight past the promise and out through the handler. EXECUTE_GAME rejected instead of resolving launch-failed, which is the exact anti-pattern gameProcessOutcomeToResult exists to end, and LOOK_FOR_A_GAME_VERSION rejected instead of reporting no version found. What reaches the player is a game version whose executable a stopped download truncated or an antivirus emptied: on Linux that is EACCES and an ordinary "couldn't run it" notice, on Windows it was the generic error the renderer shows for an exception, with none of the log lines the failure path writes. Both spawns now catch it and settle the way the error event does. The two tests pinning it drive the throw through a spawn wrapper rather than through a real Windows failure, so they hold the contract on every platform rather than only where the bug shows.
|
The seventeen are dealt with and the Windows job is green: 137 files, 1609 passing, 28 skipped, coverage at 91.94 statements, 89.07 branches, 91.41 functions and 93.47 lines, every floor clear. Linux is unchanged at 1635 passing and 2 skipped. Sixteen of the seventeen were the tests being wrong about Windows. One was not, and it was hiding behind twelve of them. The bugEvery EXECUTE_GAME fixture in gameHandlers.test.ts writes a game binary called
A player reaches this with a game version whose executable a stopped download truncated, or that an antivirus emptied in place. On Linux that is EACCES, the event fires, and they get the ordinary "couldn't run it" notice. On Windows they got the generic error the renderer shows for any exception, and none of the log lines the failure path writes. Both spawns now catch the throw and settle it the way the error event does. Two tests pin it, and they drive the throw through a spawn wrapper rather than through a real Windows failure, so they hold the contract on Linux too rather than only where the bug shows. The verdicts
On the adoption pairSince the brief singled those two out, the reading is worth stating plainly. Nothing in the adoption path branches on platform. Odds and endsOne test in gameHandlers.test.ts was passing on Windows for the wrong reason. It makes the version folder unlistable with chmod 0o000 to cover the readdir failure arm, and since NTFS has no such bits the folder lists fine, comes back empty, and yields Two rounds of CI. The first took seventeen down to nine, all nine being the spawn throw the fixtures had been hiding. The second was green. |
Zaldaryon
left a comment
There was a problem hiding this comment.
Requesting changes on one point, and it is not in the code. The production fix is correct, the premise behind it holds, every skip is honest, and the fixture rename is right. Local gates pass here: typecheck, lint:ci at 0 errors and 15 pre-existing warnings, format:check, test:coverage at 92.7 statements / 89.8 branches / 92.12 functions / 94.19 lines, all over the floors; gameHandlers.test.ts is 26 passing. CI on 37e86de is green including the new test (windows-latest).
Blocking: the matrix renames a required status check out of existence
dev branch protection requires these contexts, with enforce_admins: true:
typecheck, lint, test, build (ubuntu-latest), build (windows-latest)
Once test gains strategy.matrix.os, GitHub reports its runs as test (ubuntu-latest) and test (windows-latest). The context test is never produced again. The moment this merges, every PR to dev, this one included, sits at "Expected, waiting for status to be reported: test", and with enforce_admins: true nobody can merge past it. The repo has been here before: build (ubuntu-latest) / build (windows-latest) are in the required list precisely because build was matrixed earlier.
Two ways out, not equivalent:
- An admin edits the required contexts from
testtotest (ubuntu-latest)+test (windows-latest). This needs a coordinated window (flip it early and every other open PR blocks on contexts it does not produce; flip it late and this PR cannot merge) and it needs a maintainer, so the PR cannot carry it. - Keep a job literally named
test. Rename the matrix jobtest-matrix, add a gate job:
test:
needs: [test-matrix]
if: always()
runs-on: ubuntu-latest
steps:
- run: '[ "${{ needs.test-matrix.result }}" = "success" ]'if: always() and the explicit result check matter: GitHub counts a skipped required job as satisfied, so a naive gate would pass a red matrix. Option 2 needs no protection change and no window. I would take it.
The production fix (commit 37e86de) is correct
Verified the synchronous-throw premise: spawn routes only ENOENT / EACCES / EAGAIN / EMFILE / ENFILE through the error event and throws everything else from the call itself. Reproduced locally: spawn("/etc/hostname/nope") throws ENOTDIR synchronously. So the try/catch is on the right side of the call. Pre-fix the throw landed inside the new Promise executor and rejected the promise, and the await in the handler turned that into a rejected invoke.
Both catch blocks match the error event path: EXECUTE_GAME's settle({ started: false, error }) is byte-identical to the event handler; the probe's resolve({ ok: false, stdout: "", error }) differs from the event only in stdout, which is provably "" at that point. The probe's resolve rather than settle is necessary, not stylistic: settle closes over const timer, which is declared after the try, so calling it from the catch would hit the TDZ and throw a ReferenceError inside the executor. The comment is accurate.
settle({ started: false }) reaches the renderer as launch-failed and the errorExecutingGame toast, not as a crash, and does not credit playtime. The change also restores the documented port contract in src/domain/ports.ts:310 ("resolves once the process exits, never rejecting: a spawn failure is reported through started: false"), which the bare spawn was violating. spawnInstaller in pathsHandlers.ts:527, the Inno installer spawn, was already wrapped this way, so this brings gameHandlers in line with the existing pattern. No other unguarded spawn with the same problem in the repo.
Worth addressing, not blocking
- The commit message names the wrong spawn as the user-visible bug. On EXECUTE_GAME,
MainMenu.tsx:106already wrapsrunGamein try/catch and shows the sameerrorExecutingGametoast thatlaunch-failedmaps to, so the player saw the identical message either way. The genuine silent failure is on the other spawn:useLookForAVersion.ts:39callslookForAGameVersionwith no try/catch anywhere indetectFolder, so pre-fix a truncated or quarantinedVintagestory.exeon Windows rejected the invoke,setFolderandaddNotificationnever ran, and the "add an existing install" dialog silently did nothing. That is the failure worth naming in the PR description. spawnThrow.nextis not reset between tests.tests/ipc/gameHandlers.test.ts:57:afterEachonly callsvi.restoreAllMocks(), which does not touch avi.hoistedobject. Both tests assert consumption so they pass today, but if either fails before reaching the spawn, the flag leaks and the next test's real spawn throws. One line inbeforeEach:spawnThrow.next = false.settle/resolveasymmetry in the probe is safe now, fragile later. Anyone moving thetimerdeclaration above thetrysilently reintroduces a double-settle path. Declaringlet timerbeforesettleand usingsettlein both catches removes the asymmetry the comment has to explain.- The
UNKNOWNerror code in the comment and the fixture ({ code: "UNKNOWN", errno: -4094 }) may not be what Windows actually produced.ERROR_BAD_EXE_FORMATmaps toENOEXEC,EFTYPE, orUNKNOWNdepending on the libuv version. Behaviour is unaffected (none is in the five-code list, so the throw happens regardless, and production only callsgetErrorMessage), but confirm the code from the failing Windows run and correct the comment if needed. - No
.gitattributes. TheaccountLoginFlow.test.tsCRLF fix is correct, buttests/security-boundaries.test.ts:98andtests/renderer-dom/moddbVisibilityPrompt.test.tsx:50read source text the same way and pass only because neither uses a\n-based assertion. A repo-level* text=auto eol=lfwould immunise all three.
Verified as sound
Every one of the 13 new skipIf sites is process.platform === "win32" (none inverted). The chmod-readback skips are unwritable on NTFS, not merely failing. The CHANGE_PERMS skips are not hiding a gap: the handler returns false before touching arguments on non-Linux, and TaskManagerContext.tsx:290 already documents and discards that. The Vintagestory to Vintagestory.exe per-platform rename matches gameExecutableCandidates (.exe only on win32, [] on darwin where buildGameLaunchPlan short-circuits anyway). The removed existsSync(..., "vintagestory") === false assertion is subsumed by the full readdirSync listing on the line above. sonarcloud is untouched (own test:coverage on ubuntu, no needs:, not required, no artifact collision). fail-fast: false matches the build job. Windows npm ci is already proven by build (windows-latest). This repo is public, so the Windows runner 2x multiplier costs nothing.
One thing for the maintainer to be aware of: vitest.config.ts floors (89/87/85/85) were calibrated on Linux numbers. This PR makes them a second gate the Windows leg must clear on its own (Pixnop reports 91.94/89.07/91.41/93.47, statements the tightest at +2.07). Any future skipIf(win32) eats that margin and can fail only the Windows leg.
Not checked here
The exact Windows error code, and the Windows leg itself (no Windows host).
dev branch protection requires a status context literally named "test", and a matrixed job cannot produce one: it reports "test (ubuntu-latest)" and "test (windows-latest)" instead. Rename the matrix job to test-matrix and add a small gate job that keeps the required name, so the protection rule needs no coordinated edit. The gate runs with always() because a plain needs would skip it when a leg fails, and protection counts a skipped required job as satisfied. It then compares needs.test-matrix.result against success, which is only the case when every leg passed, so a failed, cancelled or skipped matrix turns the gate red.
|
You are right, and I checked the protection rule myself before touching anything. So the four-line diff was a trap. Once Took your option 2, since it is the one the PR can carry without a coordinated window. The matrix job is now test:
needs: [test-matrix]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
echo "test-matrix result: ${{ needs.test-matrix.result }}"
[ "${{ needs.test-matrix.result }}" = "success" ]Both halves are load bearing, as you said. Without About the failing case, I want to be honest about how far I went. I did not actually break a leg to watch the gate turn red, because doing that means pushing a deliberately broken commit onto this branch and then reverting it, which leaves noise in the history you are reviewing. What I have instead is the argument above plus the shape of the expression: The proof that matters is on this run: A context literally named Local gates on this commit: typecheck clean, Leaving your non-blocking points for a separate pass rather than folding them into this one. And if the maintainers would rather have option 1 later, editing the required contexts to the two matrix names, then the gate job becomes dead weight and can be deleted in that same change. |
…line endings Three follow-ups from review, all in the same file set. The probe's spawn catch resolved the promise directly while every other exit from that executor went through settle, because settle closed over a timer declared below the try and calling it earlier would have hit the temporal dead zone. The timer now starts as undefined above settle, so the catch settles like the "error" event does, clearTimeout ignoring an undefined handle. An asymmetry in how a spawn failure settles is the same family as the bug this branch fixes, and it was one moved declaration away from becoming a double-settle. spawnThrow.next is a vi.hoisted object, which vi.restoreAllMocks does not touch, so it now resets in beforeEach next to the rest of the per-test state. No test passes for the wrong reason today: both tests that set the flag assert it was consumed. The leak needs a test to fail before it reaches the spawn, and then it lands on whichever test runs next. .gitattributes normalises text to LF in the repository and on checkout, which is what a CRLF checkout of accountHandlers.ts needed on the Windows job. Two other tests read source the same way and pass only because neither asserts across a line ending. The crafted fixtures are marked binary so nothing rewrites a byte inside them; tests/fixtures/not-a-zip.bin is the one that needs it, since it holds no NUL byte and text=auto would otherwise treat it as text. Nothing in the index is CRLF today, so this renormalises no existing file.
|
Landed your four non-blocking points here rather than in a follow-up, so the same file set gets one review instead of two.
The
The commit message on Checks on this push, with the required context still produced: Locally: typecheck clean, |
Zaldaryon
left a comment
There was a problem hiding this comment.
Approved.
The follow-up commits close the blocking CI issue and the Windows test failures. The matrix now runs test-matrix on Ubuntu and Windows, while the required test gate runs with always() and fails unless needs.test-matrix.result is success. The dev protection rule requires typecheck, lint, test, build (ubuntu-latest), and build (windows-latest), and this run reports all five required contexts successfully.
The production fix in src/ipc/handlers/gameHandlers.ts catches synchronous spawn failures in both EXECUTE_GAME and LOOK_FOR_A_GAME_VERSION, preserving their existing failure contracts. The updated fixture names, Windows-specific skips, CRLF normalization, and spawn-throw tests are scoped to the platform behavior they cover. I found no remaining correctness or compatibility blocker.
Local verification after npm ci: npm run typecheck, npm run lint:ci, npm run format:check, npm run test:coverage, and npm run build:unpack all passed. Coverage was 92.77% statements, 89.84% branches, 92.21% functions, and 94.28% lines, with 1,635 tests passed and 2 skipped.
Issue #267 pointed out that the test job in ci.yml only ever runs on ubuntu, even though the suite has Windows-specific coverage: pathsHandlersWin32, the atomic-write crash tests whose rename semantics differ on Windows, and the backgroundHandlers symlink cases that skip themselves on win32. Any of those paths could regress on Windows and CI would still show green.
This change extends the test job to the same os matrix the build job already uses (ubuntu-latest and windows-latest), rather than spinning up a separate windows-only job that runs a subset of files. The build job proves the matrix pattern already works for this repo, and running the full suite is simpler to maintain than picking out which files count as "win32-relevant" and keeping that list in sync as tests get added. The diff is four lines.
The sonarcloud job is untouched. It already runs its own independent test:coverage step on ubuntu-latest only and feeds that into the SonarCloud scan, so this change doesn't add a second source of coverage data into Sonar. Windows just gets its own test run with no reporting side effect.
For the time cost, the build job's windows-latest run has been taking around one and a half to two minutes recently. Since jobs in a workflow run in parallel, this shouldn't add wall-clock time to a typical CI run, but it does add roughly that much in billed Actions minutes, and Windows runners bill at a 2x multiplier over Linux.
Correction to the commit message on 37e86de
That commit names EXECUTE_GAME as the user-visible failure. It is the wrong spawn, and the message stays as it is because the review cites the commit by hash and the green CI run hangs off it, so rewriting it costs more than it fixes. The record belongs here instead.
On EXECUTE_GAME the player saw no difference.
MainMenu.tsxalready wraps the invoke in try/catch and showsnotifications.body.errorExecutingGame, andplayOutcomeNotifications.tsmapslaunch-failedto that same key, so a rejection and a cleanstarted: falseproduced the identical toast. What the fix buys there is the log lines and the_playingbookkeeping, not a new message.The genuinely silent failure was the other spawn.
useLookForAVersion.ts:34callslookForAGameVersionwith no try/catch anywhere indetectFolder, so a rejected invoke skippedsetFolder,setVersionFoundandaddNotificationalike. On Windows, pointing the "add an existing installation" dialog at a folder whoseVintagestory.exea stopped download had truncated did nothing at all: no version, no error, no reason.