helpers.ts
⎇
Raw
1import { createServer } from 'net';
2import { rmSync, mkdirSync, writeFileSync } from 'fs';
3import { spawnSync as nodeSpawnSync } from 'child_process';
4import path from 'path';
5import { Database } from 'bun:sqlite';
6import 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.
10export const DATA_DIR = process.env.DATA_DIR ?? './data-test';
11export const ADMIN_PASS = 'testpass123';
12
13/** The Go binary under test. Built with `go build -o hearthforge ./cmd/hearthforge`. */
14export 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.
19if (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. */
27function 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.
39export let PORT = 0;
40export let BASE = '';
41
42/** Server handle — the spawned Go process. */
43export type ServerHandle = ReturnType<typeof Bun.spawn>;
44
45export 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 */
68export 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
103export 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
115let handle: Database | null = null;
116
117function 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
125function closeDb() {
126 handle?.close();
127 handle = null;
128}
129
130type Row = Record<string, unknown>;
131
132class 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
177class 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
194class 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
211export 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
219export 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
231export async function logout(page: Page) {
232 await page.click('form[action="/logout"] button');
233 await page.waitForURL(BASE + '/');
234}
235
236const git = (args: string[], cwd?: string) =>
237 nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' });
238
239export 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. */
245export 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 writeFileSync(`${tmp}/logo.svg`, `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>\n`);
255 git(['add', '-A'], tmp);
256 git(['commit', '-m', 'Initial commit'], tmp);
257 git(['push', 'origin', 'HEAD:main'], tmp);
258 } finally {
259 rmSync(tmp, { recursive: true, force: true });
260 }
261}
262
263export function writeTempFile(path: string, content: string) {
264 writeFileSync(path, content);
265}
266
267/** Commit a subdirectory with the given files into an existing repo on main. */
268export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
269 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
270 const tmp = `/tmp/hf-subdir-${Date.now()}`;
271 try {
272 git(['clone', repoDir, tmp]);
273 git(['config', 'user.email', 'test@test.com'], tmp);
274 git(['config', 'user.name', 'Test'], tmp);
275 mkdirSync(path.join(tmp, dirPath), { recursive: true });
276 for (const [fileName, content] of Object.entries(files)) {
277 writeFileSync(path.join(tmp, dirPath, fileName), content);
278 }
279 git(['add', '-A'], tmp);
280 git(['commit', '-m', `Add ${dirPath}`], tmp);
281 git(['push', 'origin', 'HEAD:main'], tmp);
282 } finally {
283 rmSync(tmp, { recursive: true, force: true });
284 }
285}
286
287/** Return the HEAD commit hash of a repo. */
288export function getHeadCommit(repoName: string): string {
289 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
290 const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], {
291 stdio: ['ignore', 'pipe', 'ignore'],
292 });
293 return result.stdout?.toString().trim() ?? '';
294}
295
296/** Create a new branch in an existing repo (from current HEAD). */
297export function seedBranch(name: string, branchName: string) {
298 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
299 const tmp = `/tmp/hf-branch-${Date.now()}`;
300 try {
301 git(['clone', repoPath, tmp]);
302 git(['config', 'user.email', 'test@test.com'], tmp);
303 git(['config', 'user.name', 'Test'], tmp);
304 git(['checkout', '-b', branchName], tmp);
305 git(['push', 'origin', branchName], tmp);
306 } finally {
307 rmSync(tmp, { recursive: true, force: true });
308 }
309}
310