screentinker/server/test/preflight-deps.test.js
ScreenTinker 601b526264 Boot: install missing dependencies and rebuild the native module before starting
scripts/upgrade.sh already runs `npm ci`, so this is not for the normal path. It is
for the ways a box ends up with the wrong node_modules, both of which present as
"server will not start" with an error naming a file rather than the action needed:

  ROLLBACK      checking out an older tag to back out a bad release restores that
                tag's package.json but not its packages. This branch removes
                google-auth-library, so a rollback to main would not boot — and you
                are rolling back because something else already broke.
  NODE UPGRADE  better-sqlite3 is compiled against one ABI. Upgrading Node makes every
                boot fail with NODE_MODULE_VERSION, which reads like database
                corruption and is not.

Runs as the FIRST statement in server.js, before any dependency is required, and uses
only Node builtins — anything it imported could be the thing that is missing. Repairs
with `npm install --omit=dev` (never `ci` on a partly-populated tree, which would
delete a working node_modules to fix one package) or `npm rebuild better-sqlite3`, and
exits with the command to run if it cannot. ST_SKIP_DEP_PREFLIGHT=1 opts out.

⚠️ The first version of the native check was WRONG and I caught it only by running it
under a real version mismatch: better-sqlite3's entry point is plain JavaScript that
loads the compiled binding lazily, so `require()` succeeds under a Node the binary was
never built for. It reported a genuinely broken install as healthy. It now opens an
in-memory database, which is what actually pulls the binding in. A test pins that,
because the failure is invisible — the check keeps passing on every machine where
nothing is wrong.

Verified: a deleted dependency is detected, installed and the server boots (200); the
ABI mismatch is detected under Node 18 against a module built for Node 20 and reported
clean under Node 20; a healthy tree costs 8ms and touches no network.

1609 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:44:26 -05:00

84 lines
4.2 KiB
JavaScript

'use strict';
/*
* The boot-time dependency check.
*
* It exists for the moments nobody is at their best: a rollback that restores an older
* package.json but not its packages, and a Node upgrade that leaves the native database module
* compiled against the wrong ABI. Both present as "server will not start", with an error naming a
* file rather than the action needed.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const preflight = require('../lib/preflight-deps');
test('a healthy install reports nothing missing and nothing broken', () => {
assert.deepEqual(preflight.missingDeps(), [], 'this tree should be complete');
assert.equal(preflight.nativeModuleBroken(), null, 'and the native module should load');
});
test('THE NATIVE CHECK CONSTRUCTS A DATABASE, it does not merely require the module', () => {
/*
* better-sqlite3's entry point is plain JavaScript that loads the compiled binding lazily, so
* `require()` SUCCEEDS under a Node whose ABI the binary was never built for. The first version
* of this check stopped at require and therefore reported a genuinely broken install — verified
* against a real Node 18 / Node 20 mismatch — as healthy.
*
* Pinned as source because the failure is invisible: the check keeps passing, on every machine
* where nothing is wrong, right up until the one where something is.
*/
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'preflight-deps.js'), 'utf8');
const fn = src.slice(src.indexOf('function nativeModuleBroken'), src.indexOf('function run('));
assert.match(fn, /new Database\(':memory:'\)/,
'nativeModuleBroken must open a database, or an ABI mismatch goes undetected');
assert.ok(!/^\s*require\('better-sqlite3'\);\s*$/m.test(fn), 'a bare require is not a load test');
});
test('missingDeps agrees with what is actually on disk', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
const declared = Object.keys(pkg.dependencies || {});
assert.ok(declared.length > 0, 'the server declares dependencies');
const reported = preflight.missingDeps();
for (const name of declared) {
const present = fs.existsSync(path.join(__dirname, '..', 'node_modules', name, 'package.json'));
assert.equal(present, !reported.includes(name), `${name}: presence and report disagree`);
}
});
test('the preflight uses only Node builtins', () => {
/*
* It runs BEFORE dependencies are installed, so anything it imported could be the very thing
* that is missing — and the failure would be the one it exists to prevent, with an extra layer
* of confusion on top.
*/
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'preflight-deps.js'), 'utf8');
const requires = [...src.matchAll(/require\('([^']+)'\)/g)].map((m) => m[1]);
const builtins = new Set(['fs', 'path', 'child_process', 'os', 'crypto', 'util']);
for (const r of requires) {
// better-sqlite3 is the thing being TESTED for loadability, not a dependency of this file.
if (r === 'better-sqlite3') continue;
assert.ok(builtins.has(r) || r.startsWith('.'), `preflight must not depend on ${r}`);
}
});
test('it can be turned off for an air-gapped host', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'preflight-deps.js'), 'utf8');
assert.match(src, /ST_SKIP_DEP_PREFLIGHT/, 'an operator who manages node_modules must be able to opt out');
const saved = process.env.ST_SKIP_DEP_PREFLIGHT;
process.env.ST_SKIP_DEP_PREFLIGHT = '1';
try { assert.doesNotThrow(() => preflight.preflight(), 'opting out must be a clean no-op'); }
finally { if (saved === undefined) delete process.env.ST_SKIP_DEP_PREFLIGHT; else process.env.ST_SKIP_DEP_PREFLIGHT = saved; }
});
test('server.js runs the preflight BEFORE requiring anything', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
const preflightAt = src.indexOf("require('./lib/preflight-deps')");
const firstDep = src.indexOf("require('express')");
assert.ok(preflightAt > -1, 'server.js must run the preflight');
assert.ok(preflightAt < firstDep, 'it must come before the first dependency, or it cannot help');
});