import { createServer } from 'net'; import { rmSync, mkdirSync, writeFileSync } from 'fs'; import { spawnSync as nodeSpawnSync } from 'child_process'; import path from 'path'; import { Database } from 'bun:sqlite'; import type { Page } from 'playwright'; // Set by tests/preload.ts before any module reads it — see the comment // there for why it cannot be assigned from this file. export const DATA_DIR = process.env.DATA_DIR ?? './data-test'; export const ADMIN_PASS = 'testpass123'; /** The Go binary under test. Built with `go build -o hearthforge ./cmd/hearthforge`. */ export const BIN = process.env.HEARTHFORGE_BIN ?? path.resolve('../hearthforge'); // If the preload did not run, DATA_DIR is still ./data and the tests would // wipe and re-init the real database. Stop before that happens. Checked here // rather than in the preload, which by definition cannot catch its own absence. if (path.resolve(DATA_DIR) === path.resolve('./data')) { throw new Error( 'Tests are pointed at the production DATA_DIR. tests/preload.ts did not run — ' + 'check that bunfig.toml is present and that bun was started from tests/.', ); } /** Bind to port 0 and return the OS-assigned free port number. */ function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = createServer(); srv.listen(0, '127.0.0.1', () => { const port = (srv.address() as { port: number }).port; srv.close((err) => (err ? reject(err) : resolve(port))); }); srv.on('error', reject); }); } // Resolved once in setupTestEnv() and re-exported for tests. export let PORT = 0; export let BASE = ''; /** Server handle — the spawned Go process. */ export type ServerHandle = ReturnType; export async function setupTestEnv() { PORT = await getFreePort(); BASE = `http://localhost:${PORT}`; rmSync(DATA_DIR, { recursive: true, force: true }); mkdirSync(`${DATA_DIR}/repos`, { recursive: true }); const r = nodeSpawnSync(BIN, ['init'], { env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS }, stdio: ['ignore', 'ignore', 'pipe'], }); if (r.status !== 0) { throw new Error(`hearthforge init failed: ${r.stderr?.toString() ?? r.error}`); } closeDb(); } /** * Start the server as a child process and wait until it answers /health. * * The Go server reads its whole configuration from the environment at * startup, so a test that needs a different setting (CI_DOCKER_SOCKET, * BASE_URL) restarts the server with `extraEnv` instead of mutating config. */ export async function spawnServer( extraEnv: Record = {}, ): Promise { const proc = Bun.spawn([BIN], { env: { ...process.env, PORT: String(PORT), DATA_DIR, SSH_DISABLED: '1', RATE_LIMIT_DISABLED: '1', BASE_URL: `http://localhost:${PORT}`, ...extraEnv, }, stdout: 'ignore', stderr: 'pipe', }); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { if (proc.exitCode !== null) { const err = await new Response(proc.stderr).text(); throw new Error(`server exited with ${proc.exitCode}: ${err}`); } try { const r = await fetch(`http://localhost:${PORT}/health`); if (r.status === 200) return proc; } catch { // not listening yet } await Bun.sleep(50); } proc.kill('SIGKILL'); throw new Error(`server did not become healthy on port ${PORT} within 15s`); } export async function killServer(proc: ServerHandle): Promise { closeDb(); proc.kill('SIGTERM'); await proc.exited; } // ── Database access ─────────────────────────────────────────────────────────── // // The server owns the database file; tests read it (and occasionally write a // fixture row) with a second connection. `db` mimics the sliver of the Kysely // API the ported tests already used, so those assertions stay untouched. let handle: Database | null = null; function conn(): Database { if (!handle) { handle = new Database(`${DATA_DIR}/hearthforge.db`); handle.exec('PRAGMA busy_timeout = 5000'); } return handle; } function closeDb() { handle?.close(); handle = null; } type Row = Record; class Select { private cols = '*'; private conds: string[] = []; private args: unknown[] = []; private order = ''; constructor(private table: string) {} select(c: string | string[]) { this.cols = (Array.isArray(c) ? c : [c]).join(', '); return this; } selectAll() { this.cols = '*'; return this; } where(col: string, op: string, val: unknown) { this.conds.push(`${col} ${op} ?`); this.args.push(val); return this; } orderBy(col: string, dir = 'asc') { this.order = ` ORDER BY ${col} ${dir}`; return this; } private sql() { const where = this.conds.length ? ` WHERE ${this.conds.join(' AND ')}` : ''; return `SELECT ${this.cols} FROM ${this.table}${where}${this.order}`; } async execute(): Promise { return conn().query(this.sql()).all(...(this.args as never[])) as Row[]; } async executeTakeFirst(): Promise { return (this.orderLimited() ?? undefined) as Row | undefined; } async executeTakeFirstOrThrow(): Promise { const row = this.orderLimited(); if (!row) throw new Error(`no row in ${this.table}`); return row as Row; } private orderLimited() { return conn() .query(`${this.sql()} LIMIT 1`) .get(...(this.args as never[])) as Row | null; } } class Insert { constructor(private table: string) {} private row: Row = {}; values(row: Row) { this.row = row; return this; } async execute() { const keys = Object.keys(this.row); conn() .query( `INSERT INTO ${this.table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, ) .run(...(keys.map((k) => this.row[k]) as never[])); } } class Delete { private conds: string[] = []; private args: unknown[] = []; constructor(private table: string) {} where(col: string, op: string, val: unknown) { this.conds.push(`${col} ${op} ?`); this.args.push(val); return this; } async execute() { const where = this.conds.length ? ` WHERE ${this.conds.join(' AND ')}` : ''; conn() .query(`DELETE FROM ${this.table}${where}`) .run(...(this.args as never[])); } } export const db = { selectFrom: (t: string) => new Select(t), insertInto: (t: string) => new Insert(t), deleteFrom: (t: string) => new Delete(t), }; // ── UI helpers ──────────────────────────────────────────────────────────────── export async function login( page: Page, username = 'admin', password = ADMIN_PASS, ) { await page.goto(`${BASE}/login`); await page.fill('[name=username]', username); await page.fill('[name=password]', password); await page.click('button[type=submit]'); await page.waitForURL(BASE + '/'); } export async function logout(page: Page) { await page.click('form[action="/logout"] button'); await page.waitForURL(BASE + '/'); } const git = (args: string[], cwd?: string) => nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' }); export function gitOutput(args: string[], cwd?: string): string { const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] }); return r.stdout?.toString().trim() ?? ''; } /** Push an initial commit into a bare repo that already exists on disk. */ export function seedRepo(name: string) { const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; const tmp = `/tmp/hf-seed-${Date.now()}`; try { git(['clone', repoPath, tmp]); git(['config', 'user.email', 'test@test.com'], tmp); git(['config', 'user.name', 'Test'], tmp); writeFileSync(`${tmp}/README.md`, `# ${name}\n`); writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`); writeFileSync(`${tmp}/logo.svg`, `\n`); git(['add', '-A'], tmp); git(['commit', '-m', 'Initial commit'], tmp); git(['push', 'origin', 'HEAD:main'], tmp); } finally { rmSync(tmp, { recursive: true, force: true }); } } export function writeTempFile(path: string, content: string) { writeFileSync(path, content); } /** Commit a subdirectory with the given files into an existing repo on main. */ export function seedSubdir(repoName: string, dirPath: string, files: Record) { const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; const tmp = `/tmp/hf-subdir-${Date.now()}`; try { git(['clone', repoDir, tmp]); git(['config', 'user.email', 'test@test.com'], tmp); git(['config', 'user.name', 'Test'], tmp); mkdirSync(path.join(tmp, dirPath), { recursive: true }); for (const [fileName, content] of Object.entries(files)) { writeFileSync(path.join(tmp, dirPath, fileName), content); } git(['add', '-A'], tmp); git(['commit', '-m', `Add ${dirPath}`], tmp); git(['push', 'origin', 'HEAD:main'], tmp); } finally { rmSync(tmp, { recursive: true, force: true }); } } /** Return the HEAD commit hash of a repo. */ export function getHeadCommit(repoName: string): string { const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { stdio: ['ignore', 'pipe', 'ignore'], }); return result.stdout?.toString().trim() ?? ''; } /** Create a new branch in an existing repo (from current HEAD). */ export function seedBranch(name: string, branchName: string) { const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; const tmp = `/tmp/hf-branch-${Date.now()}`; try { git(['clone', repoPath, tmp]); git(['config', 'user.email', 'test@test.com'], tmp); git(['config', 'user.name', 'Test'], tmp); git(['checkout', '-b', branchName], tmp); git(['push', 'origin', branchName], tmp); } finally { rmSync(tmp, { recursive: true, force: true }); } }