ZDDC/tests/lib/server.js
2026-06-11 13:32:31 -05:00

166 lines
5.4 KiB
JavaScript

// server.js — fixture for tests that need a running zddc-server.
//
// Exported `startMaster()` builds the binary if needed (via the canonical
// podman/zddc-go:1.24 invocation from AGENTS.md), seeds a minimal master
// root in a tempdir, spawns the binary on a free port, and resolves once
// it's listening. Returns { baseURL, root, stop() }.
//
// The build step is skipped when ZDDC_TEST_BIN is set in the environment;
// otherwise the binary is cached at <repo>/zddc/dist/zddc-server-test
// with a sibling .hash file holding the SHA256 of the source tree
// (cmd/+internal/+go.{mod,sum}). A second test run reuses the cache when
// the hash is unchanged.
//
// CommonJS form because Playwright's loader handles top-level ESM
// `import` in *.spec.js but doesn't transform the .js helpers we ship
// alongside; mixing leads to "exports is not defined in ES module
// scope" at the helper's first line. Spec files use `import { ... }
// from './lib/server.js'`; the import resolves through CommonJS interop.
const { spawn, execFileSync } = require('child_process');
const fs = require('fs');
const { tmpdir } = require('os');
const { join, resolve, dirname } = require('path');
const { createHash } = require('crypto');
const net = require('net');
const REPO_ROOT = resolve(__dirname, '..', '..');
async function freePort() {
return await new Promise((res, rej) => {
const srv = net.createServer();
srv.unref();
srv.on('error', rej);
srv.listen(0, '127.0.0.1', () => {
const { port } = srv.address();
srv.close(() => res(port));
});
});
}
function sourceHash() {
const dirs = [join(REPO_ROOT, 'zddc', 'cmd'), join(REPO_ROOT, 'zddc', 'internal')];
const files = [join(REPO_ROOT, 'zddc', 'go.mod'), join(REPO_ROOT, 'zddc', 'go.sum')];
const h = createHash('sha256');
function visit(p) {
const s = fs.statSync(p);
if (s.isDirectory()) {
for (const e of fs.readdirSync(p).sort()) visit(join(p, e));
} else {
h.update(p);
h.update(fs.readFileSync(p));
}
}
for (const d of dirs) if (fs.existsSync(d)) visit(d);
for (const f of files) if (fs.existsSync(f)) visit(f);
return h.digest('hex');
}
function ensureBinary() {
if (process.env.ZDDC_TEST_BIN) return process.env.ZDDC_TEST_BIN;
const binDir = join(REPO_ROOT, 'zddc', 'dist');
const binPath = join(binDir, 'zddc-server-test');
const stampPath = join(binDir, 'zddc-server-test.hash');
const wantHash = sourceHash();
if (fs.existsSync(binPath) && fs.existsSync(stampPath)) {
const have = fs.readFileSync(stampPath, 'utf8').trim();
if (have === wantHash) return binPath;
}
fs.mkdirSync(binDir, { recursive: true });
const args = [
'run', '--rm', '--network=host',
'-v', `${REPO_ROOT}:/src:Z`,
'-v', `/tmp/gocache:/root/go/pkg/mod:Z`,
'-v', `${binDir}:/out:Z`,
'-w', '/src/zddc',
'-e', 'GOPROXY=https://proxy.golang.org',
'-e', 'GOSUMDB=off',
'-e', 'GOPRIVATE=',
'localhost/zddc-go:1.24',
'go', 'build', '-o', '/out/zddc-server-test', './cmd/zddc-server',
];
execFileSync('podman', args, { stdio: 'inherit' });
fs.writeFileSync(stampPath, wantHash);
return binPath;
}
async function waitListening(url, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(url, { method: 'GET' });
if (r.status >= 200 && r.status < 500) return;
} catch {
// not listening yet
}
await new Promise(r => setTimeout(r, 100));
}
throw new Error(`server did not start listening at ${url} within ${timeoutMs}ms`);
}
async function startMaster(opts = {}) {
const adminEmail = opts.adminEmail || 'alice@example.com';
const noAuth = opts.noAuth === true;
const root = fs.mkdtempSync(join(tmpdir(), 'zddc-test-'));
fs.writeFileSync(join(root, '.zddc'), [
`title: "Playwright test fixture"`,
`admins:`,
` - ${adminEmail}`,
`acl:`,
` permissions:`,
` "${adminEmail}": rwcda`,
` "*@example.com": r`,
'',
].join('\n'));
const bin = ensureBinary();
const port = await freePort();
const baseURL = `http://127.0.0.1:${port}`;
const args = [
'--root', root,
'--addr', `127.0.0.1:${port}`,
'--tls-cert=none',
];
if (noAuth) args.push('--no-auth');
const proc = spawn(bin, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
const buf = [];
proc.stdout.on('data', d => buf.push(d.toString()));
proc.stderr.on('data', d => buf.push(d.toString()));
let exited = false;
proc.on('exit', () => { exited = true; });
try {
await waitListening(`${baseURL}/`, 10000);
} catch (e) {
proc.kill('SIGTERM');
throw new Error(`startMaster failed: ${e.message}\nServer logs:\n${buf.join('')}`);
}
return {
baseURL,
root,
proc,
logs: () => buf.join(''),
async stop() {
if (exited) return;
proc.kill('SIGTERM');
await new Promise(res => {
const t = setTimeout(() => { proc.kill('SIGKILL'); res(); }, 3000);
proc.on('exit', () => { clearTimeout(t); res(); });
});
},
};
}
module.exports = { startMaster };