166 lines
5.4 KiB
JavaScript
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 };
|