helpers.ts
| 1 | import { createServer } from 'net'; |
| 2 | import { rmSync, mkdirSync, writeFileSync } from 'fs'; |
| 3 | import { spawnSync as nodeSpawnSync } from 'child_process'; |
| 4 | import path from 'path'; |
| 5 | import { Database } from 'bun:sqlite'; |
| 6 | import type { Page } from 'playwright'; |
| 7 | |
| 8 | // Set by tests/preload.ts before any module reads it — see the comment |
| 9 | // there for why it cannot be assigned from this file. |
| 10 | export const DATA_DIR = process.env.DATA_DIR ?? './data-test'; |
| 11 | export const ADMIN_PASS = 'testpass123'; |
| 12 | |
| 13 | /** The Go binary under test. Built with `go build -o hearthforge ./cmd/hearthforge`. */ |
| 14 | export const BIN = process.env.HEARTHFORGE_BIN ?? path.resolve('../hearthforge'); |
| 15 | |
| 16 | // If the preload did not run, DATA_DIR is still ./data and the tests would |
| 17 | // wipe and re-init the real database. Stop before that happens. Checked here |
| 18 | // rather than in the preload, which by definition cannot catch its own absence. |
| 19 | if (path.resolve(DATA_DIR) === path.resolve('./data')) { |
| 20 | throw new Error( |
| 21 | 'Tests are pointed at the production DATA_DIR. tests/preload.ts did not run — ' + |
| 22 | 'check that bunfig.toml is present and that bun was started from tests/.', |
| 23 | ); |
| 24 | } |
| 25 | |
| 26 | /** Bind to port 0 and return the OS-assigned free port number. */ |
| 27 | function getFreePort(): Promise<number> { |
| 28 | return new Promise((resolve, reject) => { |
| 29 | const srv = createServer(); |
| 30 | srv.listen(0, '127.0.0.1', () => { |
| 31 | const port = (srv.address() as { port: number }).port; |
| 32 | srv.close((err) => (err ? reject(err) : resolve(port))); |
| 33 | }); |
| 34 | srv.on('error', reject); |
| 35 | }); |
| 36 | } |
| 37 | |
| 38 | // Resolved once in setupTestEnv() and re-exported for tests. |
| 39 | export let PORT = 0; |
| 40 | export let BASE = ''; |
| 41 | |
| 42 | /** Server handle — the spawned Go process. */ |
| 43 | export type ServerHandle = ReturnType<typeof Bun.spawn>; |
| 44 | |
| 45 | export async function setupTestEnv() { |
| 46 | PORT = await getFreePort(); |
| 47 | BASE = `http://localhost:${PORT}`; |
| 48 | rmSync(DATA_DIR, { recursive: true, force: true }); |
| 49 | mkdirSync(`${DATA_DIR}/repos`, { recursive: true }); |
| 50 | |
| 51 | const r = nodeSpawnSync(BIN, ['init'], { |
| 52 | env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS }, |
| 53 | stdio: ['ignore', 'ignore', 'pipe'], |
| 54 | }); |
| 55 | if (r.status !== 0) { |
| 56 | throw new Error(`hearthforge init failed: ${r.stderr?.toString() ?? r.error}`); |
| 57 | } |
| 58 | closeDb(); |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Start the server as a child process and wait until it answers /health. |
| 63 | * |
| 64 | * The Go server reads its whole configuration from the environment at |
| 65 | * startup, so a test that needs a different setting (CI_DOCKER_SOCKET, |
| 66 | * BASE_URL) restarts the server with `extraEnv` instead of mutating config. |
| 67 | */ |
| 68 | export async function spawnServer( |
| 69 | extraEnv: Record<string, string> = {}, |
| 70 | ): Promise<ServerHandle> { |
| 71 | const proc = Bun.spawn([BIN], { |
| 72 | env: { |
| 73 | ...process.env, |
| 74 | PORT: String(PORT), |
| 75 | DATA_DIR, |
| 76 | SSH_DISABLED: '1', |
| 77 | RATE_LIMIT_DISABLED: '1', |
| 78 | BASE_URL: `http://localhost:${PORT}`, |
| 79 | ...extraEnv, |
| 80 | }, |
| 81 | stdout: 'ignore', |
| 82 | stderr: 'pipe', |
| 83 | }); |
| 84 | |
| 85 | const deadline = Date.now() + 15_000; |
| 86 | while (Date.now() < deadline) { |
| 87 | if (proc.exitCode !== null) { |
| 88 | const err = await new Response(proc.stderr).text(); |
| 89 | throw new Error(`server exited with ${proc.exitCode}: ${err}`); |
| 90 | } |
| 91 | try { |
| 92 | const r = await fetch(`http://localhost:${PORT}/health`); |
| 93 | if (r.status === 200) return proc; |
| 94 | } catch { |
| 95 | // not listening yet |
| 96 | } |
| 97 | await Bun.sleep(50); |
| 98 | } |
| 99 | proc.kill('SIGKILL'); |
| 100 | throw new Error(`server did not become healthy on port ${PORT} within 15s`); |
| 101 | } |
| 102 | |
| 103 | export async function killServer(proc: ServerHandle): Promise<void> { |
| 104 | closeDb(); |
| 105 | proc.kill('SIGTERM'); |
| 106 | await proc.exited; |
| 107 | } |
| 108 | |
| 109 | // ── Database access ─────────────────────────────────────────────────────────── |
| 110 | // |
| 111 | // The server owns the database file; tests read it (and occasionally write a |
| 112 | // fixture row) with a second connection. `db` mimics the sliver of the Kysely |
| 113 | // API the ported tests already used, so those assertions stay untouched. |
| 114 | |
| 115 | let handle: Database | null = null; |
| 116 | |
| 117 | function conn(): Database { |
| 118 | if (!handle) { |
| 119 | handle = new Database(`${DATA_DIR}/hearthforge.db`); |
| 120 | handle.exec('PRAGMA busy_timeout = 5000'); |
| 121 | } |
| 122 | return handle; |
| 123 | } |
| 124 | |
| 125 | function closeDb() { |
| 126 | handle?.close(); |
| 127 | handle = null; |
| 128 | } |
| 129 | |
| 130 | type Row = Record<string, unknown>; |
| 131 | |
| 132 | class Select { |
| 133 | private cols = '*'; |
| 134 | private conds: string[] = []; |
| 135 | private args: unknown[] = []; |
| 136 | private order = ''; |
| 137 | constructor(private table: string) {} |
| 138 | select(c: string | string[]) { |
| 139 | this.cols = (Array.isArray(c) ? c : [c]).join(', '); |
| 140 | return this; |
| 141 | } |
| 142 | selectAll() { |
| 143 | this.cols = '*'; |
| 144 | return this; |
| 145 | } |
| 146 | where(col: string, op: string, val: unknown) { |
| 147 | this.conds.push(`${col} ${op} ?`); |
| 148 | this.args.push(val); |
| 149 | return this; |
| 150 | } |
| 151 | orderBy(col: string, dir = 'asc') { |
| 152 | this.order = ` ORDER BY ${col} ${dir}`; |
| 153 | return this; |
| 154 | } |
| 155 | private sql() { |
| 156 | const where = this.conds.length ? ` WHERE ${this.conds.join(' AND ')}` : ''; |
| 157 | return `SELECT ${this.cols} FROM ${this.table}${where}${this.order}`; |
| 158 | } |
| 159 | async execute(): Promise<Row[]> { |
| 160 | return conn().query(this.sql()).all(...(this.args as never[])) as Row[]; |
| 161 | } |
| 162 | async executeTakeFirst(): Promise<Row | undefined> { |
| 163 | return (this.orderLimited() ?? undefined) as Row | undefined; |
| 164 | } |
| 165 | async executeTakeFirstOrThrow(): Promise<Row> { |
| 166 | const row = this.orderLimited(); |
| 167 | if (!row) throw new Error(`no row in ${this.table}`); |
| 168 | return row as Row; |
| 169 | } |
| 170 | private orderLimited() { |
| 171 | return conn() |
| 172 | .query(`${this.sql()} LIMIT 1`) |
| 173 | .get(...(this.args as never[])) as Row | null; |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | class Insert { |
| 178 | constructor(private table: string) {} |
| 179 | private row: Row = {}; |
| 180 | values(row: Row) { |
| 181 | this.row = row; |
| 182 | return this; |
| 183 | } |
| 184 | async execute() { |
| 185 | const keys = Object.keys(this.row); |
| 186 | conn() |
| 187 | .query( |
| 188 | `INSERT INTO ${this.table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, |
| 189 | ) |
| 190 | .run(...(keys.map((k) => this.row[k]) as never[])); |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | class Delete { |
| 195 | private conds: string[] = []; |
| 196 | private args: unknown[] = []; |
| 197 | constructor(private table: string) {} |
| 198 | where(col: string, op: string, val: unknown) { |
| 199 | this.conds.push(`${col} ${op} ?`); |
| 200 | this.args.push(val); |
| 201 | return this; |
| 202 | } |
| 203 | async execute() { |
| 204 | const where = this.conds.length ? ` WHERE ${this.conds.join(' AND ')}` : ''; |
| 205 | conn() |
| 206 | .query(`DELETE FROM ${this.table}${where}`) |
| 207 | .run(...(this.args as never[])); |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | export const db = { |
| 212 | selectFrom: (t: string) => new Select(t), |
| 213 | insertInto: (t: string) => new Insert(t), |
| 214 | deleteFrom: (t: string) => new Delete(t), |
| 215 | }; |
| 216 | |
| 217 | // ── UI helpers ──────────────────────────────────────────────────────────────── |
| 218 | |
| 219 | export async function login( |
| 220 | page: Page, |
| 221 | username = 'admin', |
| 222 | password = ADMIN_PASS, |
| 223 | ) { |
| 224 | await page.goto(`${BASE}/login`); |
| 225 | await page.fill('[name=username]', username); |
| 226 | await page.fill('[name=password]', password); |
| 227 | await page.click('button[type=submit]'); |
| 228 | await page.waitForURL(BASE + '/'); |
| 229 | } |
| 230 | |
| 231 | export async function logout(page: Page) { |
| 232 | await page.click('form[action="/logout"] button'); |
| 233 | await page.waitForURL(BASE + '/'); |
| 234 | } |
| 235 | |
| 236 | const git = (args: string[], cwd?: string) => |
| 237 | nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' }); |
| 238 | |
| 239 | export function gitOutput(args: string[], cwd?: string): string { |
| 240 | const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] }); |
| 241 | return r.stdout?.toString().trim() ?? ''; |
| 242 | } |
| 243 | |
| 244 | /** Push an initial commit into a bare repo that already exists on disk. */ |
| 245 | export function seedRepo(name: string) { |
| 246 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 247 | const tmp = `/tmp/hf-seed-${Date.now()}`; |
| 248 | try { |
| 249 | git(['clone', repoPath, tmp]); |
| 250 | git(['config', 'user.email', 'test@test.com'], tmp); |
| 251 | git(['config', 'user.name', 'Test'], tmp); |
| 252 | writeFileSync(`${tmp}/README.md`, `# ${name}\n`); |
| 253 | writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`); |
| 254 | git(['add', '-A'], tmp); |
| 255 | git(['commit', '-m', 'Initial commit'], tmp); |
| 256 | git(['push', 'origin', 'HEAD:main'], tmp); |
| 257 | } finally { |
| 258 | rmSync(tmp, { recursive: true, force: true }); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | export function writeTempFile(path: string, content: string) { |
| 263 | writeFileSync(path, content); |
| 264 | } |
| 265 | |
| 266 | /** Commit a subdirectory with the given files into an existing repo on main. */ |
| 267 | export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) { |
| 268 | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; |
| 269 | const tmp = `/tmp/hf-subdir-${Date.now()}`; |
| 270 | try { |
| 271 | git(['clone', repoDir, tmp]); |
| 272 | git(['config', 'user.email', 'test@test.com'], tmp); |
| 273 | git(['config', 'user.name', 'Test'], tmp); |
| 274 | mkdirSync(path.join(tmp, dirPath), { recursive: true }); |
| 275 | for (const [fileName, content] of Object.entries(files)) { |
| 276 | writeFileSync(path.join(tmp, dirPath, fileName), content); |
| 277 | } |
| 278 | git(['add', '-A'], tmp); |
| 279 | git(['commit', '-m', `Add ${dirPath}`], tmp); |
| 280 | git(['push', 'origin', 'HEAD:main'], tmp); |
| 281 | } finally { |
| 282 | rmSync(tmp, { recursive: true, force: true }); |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /** Return the HEAD commit hash of a repo. */ |
| 287 | export function getHeadCommit(repoName: string): string { |
| 288 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; |
| 289 | const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { |
| 290 | stdio: ['ignore', 'pipe', 'ignore'], |
| 291 | }); |
| 292 | return result.stdout?.toString().trim() ?? ''; |
| 293 | } |
| 294 | |
| 295 | /** Create a new branch in an existing repo (from current HEAD). */ |
| 296 | export function seedBranch(name: string, branchName: string) { |
| 297 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 298 | const tmp = `/tmp/hf-branch-${Date.now()}`; |
| 299 | try { |
| 300 | git(['clone', repoPath, tmp]); |
| 301 | git(['config', 'user.email', 'test@test.com'], tmp); |
| 302 | git(['config', 'user.name', 'Test'], tmp); |
| 303 | git(['checkout', '-b', branchName], tmp); |
| 304 | git(['push', 'origin', branchName], tmp); |
| 305 | } finally { |
| 306 | rmSync(tmp, { recursive: true, force: true }); |
| 307 | } |
| 308 | } |
| 309 |