import { spawn } from 'node:child_process' import { access } from 'node:fs/promises' import { resolve } from 'node:path' import { deflateSync } from 'node:zlib' import env from '#start/env' export const GROUP = 'alt.binaries.e2e' /** The edge worker binary: EDGE_NODE_BIN, else the release or debug build under edge/target. */ export async function edgeBinary(): Promise { const candidates = [ process.env.EDGE_NODE_BIN, 'edge/target/release/edge-node', 'edge/target/debug/edge-node', ] .filter((c): c is string => !!c) .map((c) => resolve(c)) for (const path of candidates) { try { await access(path) return path } catch {} } throw new Error( `No edge-node binary (looked at ${candidates.join(', ')}): build it with "cargo build --release --manifest-path edge/Cargo.toml --bin edge-node", or set EDGE_NODE_BIN` ) } /** The test server's address for the worker (tests/bootstrap.ts starts it on HOST:PORT). */ export function coordinatorUrl(): string { const host = env.get('HOST', 'localhost') return `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${env.get('PORT', 3333)}` } export interface EdgeRun { exit: Promise<{ code: number | null; signal: string | null }> lines: string[] kill: () => void } /** * The real edge worker against the test server: one thread on the history lane, one group, a batch per chunk * (100 articles in these tests), a snapshot after every range. */ export async function startEdge(token: string, args: string[] = []): Promise { const bin = await edgeBinary() // The test server first (tests/bootstrap.ts starts it for this suite): a worker that can't connect retries for // minutes before it says so. const probe = await fetch(`${coordinatorUrl()}/api/v1/node/lease`, { method: 'POST' }).catch( (e) => e as Error ) if (probe instanceof Error || probe.status !== 401) throw new Error( `The test server doesn't answer at ${coordinatorUrl()}: ${probe instanceof Error ? probe.message : `HTTP ${probe.status}`}` ) const child = spawn( bin, [ '--threads', '1', '--tail-threads', '0', '--groups', '1', '--batch-articles', '100', '--snapshot-minutes', '0', ...args, ], { env: { ...process.env, COORDINATOR_URL: coordinatorUrl(), NODE_TOKEN: token }, stdio: ['ignore', 'pipe', 'pipe'], } ) const lines: string[] = [] let rest = '' const collect = (chunk: Buffer) => { rest += chunk.toString('utf8') const parts = rest.split('\n') rest = parts.pop() ?? '' lines.push(...parts) } child.stdout.on('data', collect) child.stderr.on('data', collect) const exit = new Promise<{ code: number | null; signal: string | null }>((done) => { child.on('exit', (code, signal) => { if (rest) lines.push(rest) done({ code, signal }) }) }) return { exit, lines, kill: () => child.kill('SIGKILL') } } /** Runs the worker to its end (at most `timeoutMs`), its log lines and exit code. */ export async function runEdge(token: string, args: string[] = [], timeoutMs = 60_000) { const run = await startEdge(token, args) const timer = setTimeout(() => run.kill(), timeoutMs) const result = await run.exit clearTimeout(timer) return { ...result, lines: run.lines } } /** Polls `condition` every 100 ms until it holds or `timeoutMs` passed (then throws). */ export async function waitFor(what: string, condition: () => Promise, timeoutMs = 30_000) { const until = Date.now() + timeoutMs while (Date.now() < until) { if (await condition()) return await new Promise((r) => setTimeout(r, 100)) } throw new Error(`Timed out waiting for ${what}`) } const BASE_MS = Date.UTC(2026, 9, 5, 10) /** A file name without digits (a number sequence would make an album of a poster's files): letters from `k`. */ export function nameOf(k: number): string { let s = '' let n = k do { s = String.fromCharCode(97 + (n % 26)) + s n = Math.floor(n / 26) } while (n > 0) return `Set.${s}.mkv` } /** * The overview lines of articles `from`..`to`, one an hour from 2026-10-05: article 1 is a one-part file, and from 2 * on each even article is part 1 of a two-part file whose part 2 is the next (odd) article — so a file spans every * chunk boundary at an even `to` (100 and 101, 200 and 201: the window has to carry it). Dates an hour apart make * the posting frontier move a day every 24 articles, so the window closes on files as it goes. */ export function overviewLines(from: number, to: number): string[] { const lines: string[] = [] for (let n = from; n <= to; n++) { const date = new Date(BASE_MS + n * 3600_000).toUTCString().replace(' GMT', ' +0000') const [name, part] = n === 1 ? [nameOf(1), '(1/1)'] : n % 2 === 0 ? [nameOf(n), '(1/2)'] : [nameOf(n - 1), '(2/2)'] lines.push( `${n}\t"${name}" yEnc ${part}\tposter \t${date}\t\t\t700000\t5000\tXref: news.example ${GROUP}:${n}` ) } return lines } /** One XOVER chunk as the news server sends it (deflated). */ export function overviewChunk(from: number, to: number): Buffer { return deflateSync(Buffer.from(overviewLines(from, to).join('\r\n') + '\r\n')) }