ENOENT: scandir in CI, green on my machine, and the path was correct
A vitest suite passed locally on every run and failed the first CI job with ENOENT: scandir. The path was not wrong. The test read its fixture corpus by absolute path from a sibling repository, so a CI checkout of one repo could never contain it. The bug is a test depending on state outside the artifact under test.
TL;DR · THE FIX
ENOENT: scandir 'C:/Dev/.../migrations' in CI while vitest run is green locally means the test reads a fixture from outside the repo. Absolute paths and paths relative to process.cwd() both do this quietly. Vendor the fixture into the test tree and resolve it with fileURLToPath(new URL('./__fixtures__/x', import.meta.url)), which is relative to the test file and identical everywhere, then add a skipIf drift guard that only runs on the machine holding the original.
The symptom
Every push failed the Unit tests (vitest) job:
ENOENT: no such file or directory, scandir
'C:/Dev/Base/supabase-hardening-kit/supabase/migrations'
vitest run locally: green, every time, on the same commit.
A Windows-style absolute path in a Linux CI log is worth staring at for a second before doing anything else. CI did not invent that string. It came out of the repository, which means a path from one specific machine got committed.
The cause
The suite under test was a Postgres config auditor. Its regression corpus was four real migration files from a hardening kit, used as the reference input the checks run against. The test read them straight off disk:
// src/lib/__tests__/audit.test.js
const MIGRATIONS = 'C:/Dev/Base/supabase-hardening-kit/supabase/migrations';
const files = await readdir(MIGRATIONS);
The kit is a sibling repository. On my machine it sits next to the project and the path resolves. In CI, the runner checks out this repository and nothing else, so that directory cannot exist, and no amount of correcting the path will make it exist.
That is why “fix the path” is the wrong instinct. The test depended on state outside the artifact under test. A test that reads anything the build does not contain will pass forever on the machine where that thing happens to be sitting, and fail everywhere else. Making the path relative would have moved the same defect somewhere harder to see.
process.cwd() has the same problem for the same reason: it depends on where you were standing when you invoked the runner, not on where the file lives.
The fix
Vendor the corpus into the test tree, and resolve it relative to the test file rather than to the machine or the working directory:
import { fileURLToPath } from 'node:url';
const FIXTURES = fileURLToPath(
new URL('./__fixtures__/hardening-kit/', import.meta.url)
);
import.meta.url is the module’s own location. This resolves identically on my laptop, in CI, and from any working directory, because it does not consult any of them.
Files moved to src/lib/__fixtures__/hardening-kit/. CI run green: 100 tests across 4 files.
The second bug the fix creates
A vendored copy drifts from its source, silently. That is the standing cost of vendoring anything, and it is how a fixture ends up two years stale.
So pin them, on the one machine that has both:
import { existsSync } from 'node:fs';
const REAL_KIT = 'C:/Dev/Base/supabase-hardening-kit/supabase/migrations';
it.skipIf(!existsSync(REAL_KIT))(
'vendored fixtures match the real kit byte for byte',
async () => {
for (const name of await readdir(FIXTURES)) {
expect(await readFile(join(FIXTURES, name)))
.toEqual(await readFile(join(REAL_KIT, name)));
}
}
);
In CI this test is invisible. On the machine that holds the real kit, it fails the moment the two diverge. The absolute path is back, and now it is fine, because the test declares it is machine-specific instead of pretending otherwise.
Proving the guard works
A guard that has only ever run against a matching pair has never been tested. You have watched it not fire, which is different from watching it fire.
So: append a comment to one vendored fixture, run the suite, watch it fail, revert. Thirty seconds, and it is the difference between having a drift guard and believing you have one.
Discussion
Powered by GitHub. Sign in to leave a comment.