// 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 /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 };