screentinker/server/test/boot-health.test.js
screentinker 12c0004245
test(ci): OS-assigned ephemeral ports for subprocess suites — kill the port-collision flake (#176)
The subprocess-booting test suites hand-picked fixed ports in a cramped ~3955-4021 range, and
156-schedule-read-path deviated to a RANDOM port (3900 + rand%90) that overlapped those fixed
ports. Under CI load two servers could race on the same port, surfacing as flaky "no such table:
devices" / "FOREIGN KEY constraint failed" (a server answering a request against a half-migrated
or wrong DB). It's environmental — the suites pass locally and in isolation.

Fix: a shared test/helpers/free-port.js (bind :0 on loopback, read the OS-assigned port, release)
called in before() so every suite gets a guaranteed-unique ephemeral port — concurrent suites can
no longer collide, and no one has to hand-assign ports.

- Codemod converted 30 suites: const PORT = <fixed|random> -> let PORT (+ BASE) assigned via
  `PORT = await freePort()` at the top of before().
- 3 hand-fixed (different structure): 148-eviction-storm (lowercase `base`), boot-health (no
  before() — allocates PORT + a throwaway SEED_PORT inside the test, replacing the hardcoded
  3894), totp-keyrotation (no before() — allocates at the test start before bootServer()).

No fixed 39xx/40xx ports remain. Full server suite 435/435; the 4 hand-touched suites pass in
isolation. Pure test-infra change — no app code touched.
2026-07-13 09:51:40 -05:00

68 lines
4 KiB
JavaScript

'use strict';
// #146 P2.6 — a deploy against a PRE-BLOATED device_status_log must still bind + serve
// /api/status quickly, with the chunked startup prune trickling in the background (the
// whole point of the async/chunked startup prune — the old whole-table sort froze boot
// -> healthcheck fail -> restart loop). Seed a big backlog, boot, assert /api/status
// answers fast WHILE the table is still large, then confirm the backlog drains.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { freePort } = require('./helpers/free-port');
const DATA_DIR = path.join(os.tmpdir(), 'st-boot-' + crypto.randomBytes(4).toString('hex'));
const DBPATH = path.join(DATA_DIR, 'db', 'remote_display.db');
test('boots + serves /api/status quickly against a pre-bloated table; prune drains in background', async () => {
const PORT = await freePort();
const BASE = `http://127.0.0.1:${PORT}`;
const SEED_PORT = await freePort();
// 1) Create + migrate the DB in a throwaway boot, then seed a large backlog.
{
const p = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(SEED_PORT), NODE_ENV: 'test' }, stdio: 'ignore' });
for (let i = 0; i < 60; i++) { try { const r = await fetch(`http://127.0.0.1:${SEED_PORT}/api/status`); if (r.ok) break; } catch { /* */ } await new Promise(r => setTimeout(r, 200)); }
p.kill('SIGKILL');
await new Promise(r => setTimeout(r, 300));
}
const seed = new Database(DBPATH);
const ins = seed.prepare('INSERT INTO device_status_log (device_id, status, timestamp) VALUES (?, ?, ?)');
const now = Math.floor(Date.now() / 1000);
seed.transaction(() => { for (let i = 0; i < 300000; i++) ins.run('d' + (i % 3), 'online', now); })();
assert.equal(seed.prepare('SELECT COUNT(*) c FROM device_status_log').get().c, 300000, 'seeded 300k backlog');
seed.close();
// 2) Boot for real against the bloated table; time to first /api/status OK.
const proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test', STATUS_LOG_MAX_ROWS_PER_DEVICE: '500' }, stdio: ['ignore', fs.openSync(path.join(os.tmpdir(), 'st-boot.log'), 'w'), 'inherit'] });
try {
const t0 = Date.now();
let up = false;
for (let i = 0; i < 60; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await new Promise(r => setTimeout(r, 100)); }
const bootMs = Date.now() - t0;
assert.ok(up, 'server bound and served /api/status');
assert.ok(bootMs < 3000, `/api/status answered in ${bootMs}ms — NOT blocked by the 300k prune (old sort froze ~40s)`);
// At first-serve the prune is still trickling: the table should still be large.
const ro1 = new Database(DBPATH, { readonly: true });
const atBoot = ro1.prepare('SELECT COUNT(*) c FROM device_status_log').get().c; ro1.close();
assert.ok(atBoot > 1500, `prune runs in background — table still large at first serve (${atBoot})`);
// Give the chunked startup prune time to drain, staying responsive throughout.
for (let i = 0; i < 40; i++) {
const r = await fetch(BASE + '/api/status'); assert.ok(r.ok, 'stays responsive while pruning');
const ro = new Database(DBPATH, { readonly: true });
const c = ro.prepare('SELECT COUNT(*) c FROM device_status_log').get().c; ro.close();
if (c <= 1500) break;
await new Promise(r => setTimeout(r, 250));
}
const ro2 = new Database(DBPATH, { readonly: true });
const drained = ro2.prepare('SELECT COUNT(*) c FROM device_status_log').get().c; ro2.close();
assert.equal(drained, 1500, '3 devices x 500 cap — backlog fully drained by the background startup prune');
} finally { proc.kill('SIGKILL'); }
});