Back openDesk Edu for a sovereign, open-source education — every vote counts.
Vote nowSave products you love by clicking the heart icon.
SOGo6-dockerized has reached a major milestone: the entire CI pipeline is green, 8,500+ automated tests pass, and three missing features (ICS export, public calendar subscriptions, RE:/FWD subject prefixing) are now integrated. Here's the full progress report — and how you can test and contribute.
Every maintainer knows the shape of this failure: a dependency bot opens a routine version bump, CI goes red, and the diff between "works on my machine" and "fails in the pipeline" eats an afternoon. This one ate three days — and the root cause was a behavior change in jsdom that almost nobody has written about.
The site in question is a Next.js 16 app with a typical enterprise test setup: Vitest in jsdom mode, around 600 unit tests, a CI pipeline that runs typecheck, lint, unit tests, and an audit gate on every push. The pipeline had been green for months.
Then a dependency PR landed: next 16.2.12 → 16.3.4 (a critical security
advisory, so it had to go in), which dragged @types/react up to 19.2.x along
the way. Two failure waves followed, and they were unrelated — which is exactly
what made this miserable to debug.
The React 19 type definitions split and tightened several interfaces. ReactElement
children props that used to be inferred as ReactNode became unknown, the
OpenGraph and Twitter metadata types drifted apart, and querySelector calls
without generics started returning types that no longer matched their call sites.
Sixty-nine errors across twelve test files. Tedious, but honest work: casts where
the test genuinely knows more than the type system, generics on the DOM queries,
and a few unused variables that stricter inference surfaced. An hour, done.
That part was expected. What came next was not.
With typecheck green, the unit suite lit up: ThemeProvider — and eventually
twenty-plus components behind it — crashed on import with
localStorage is not defined. Not "permission denied", not a quota error. The
global simply did not exist.
Here's the confusing part: the application itself was fine. In any real browser,
window.localStorage is there. Only the test environment had lost it. The culprit:
jsdom 29 no longer wires a localStorage implementation into the environment
by default. Node emits a quiet hint if you look for it —
ExperimentalWarning: localStorage is not available because --localstorage-file was not provided — but in a wall of red Vitest output, nobody reads warnings.
The failure surface was also misleading. The components that crashed weren't the ones that use localStorage heavily; they were the ones that merely touch it once, defensively, inside a feature-detection guard written years ago:
const stored = window.localStorage.getItem("theme"); // throws if localStorage is undefined
A guard written for browsers — "if the API exists, use it" — assumes the API
either works or doesn't exist. jsdom 29 created a third world: the identifier
exists as a concept but the global is undefined, so feature detection by
access throws.
The tempting fixes are all bad in the same way:
localStorage in each affected test. Scatters environment assumptions
across sixty call sites and makes every future component test carry the same boilerplate.The right fix lives at the test boundary, in setup.ts, where environment
assumptions belong. A minimal, Map-backed Storage implementation — the actual
contract is tiny: getItem, setItem, removeItem, clear, key, and a
length — defined on both globalThis and window:
class StoragePolyfill {
private map = new Map<string, string>();
get length() {
return this.map.size;
}
getItem(k: string) {
return this.map.has(k) ? this.map.get(k)! : null;
}
setItem(k: string, v: string) {
this.map.set(String(k), String(v));
}
removeItem(k: string) {
this.map.delete(k);
}
clear() {
this.map.clear();
}
key(i: number) {
return [...this.map.keys()][i] ?? null;
}
}
const storage = new StoragePolyfill();
Object.defineProperty(globalThis, "localStorage", { value: storage });
Object.defineProperty(window, "localStorage", { value: storage });
Twenty lines, one place, every test green — and it behaves like the real thing
closely enough for component tests: values persist across calls within a test,
JSON.parse(getItem(...)) round-trips, clear() resets between suites. If a test
genuinely needs quota errors or persistence semantics, that's a signal it should
mock Storage explicitly — the polyfill is a floor, not a ceiling.
With all tests green locally, I pushed — and CI stayed red. Same commit, same
code. The difference: toolchain drift. My local checkout resolves TypeScript
through a Bun lockfile that had already pulled TypeScript 6.x with different
strictness behavior. CI installs from package-lock.json with npm ci —
TypeScript 5.9.3 — and sees errors the local tree does not. Lesson re-learned the
hard way: verify pipeline fixes with the pipeline's toolchain. npm ci in a
clean worktree, run the suite, then push. The repo is the source of truth, not
your laptop.
@types/*
changes and test-environment dependencies deserve the same scrutiny as
next itself.setup.ts
that the browser globals your app assumes actually exist — it converts an
afternoon of stack-trace archaeology into one explicit, readable failure.setup.ts exists
precisely so that production code never learns what jsdom is.The five-line takeaway: your tests don't run in a browser. Every year or so, the
gap between "browser-shaped" and "a browser" gets renovated, and something you
relied on quietly stops being there. This time it was localStorage. Next time
it'll be something else — and a ten-line canary in your setup file is cheap
insurance against finding out from 600 red tests instead of one.