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 type { Page } from 'playwright'; |
| 6 | import config from '../src/config.ts'; |
| 7 | import { createApp } from '../src/app.ts'; |
| 8 | import { resetDb } from '../src/db/index.ts'; |
| 9 | |
| 10 | // Set by tests/preload.ts before any module reads it — see the comment |
| 11 | // there for why it cannot be assigned from this file. |
| 12 | export const DATA_DIR = process.env.DATA_DIR ?? './data-test'; |
| 13 | export const ADMIN_PASS = 'testpass123'; |
| 14 | |
| 15 | // If the preload did not run, DATA_DIR is still ./data and src/db/index.ts has |
| 16 | // already migrated the real database in its module body. Stop before any test |
| 17 | // also authenticates against the live admin row. Checked here rather than in |
| 18 | // 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 the repo root.', |
| 23 | ); |
| 24 | } |
| 25 | |
| 26 | // DATA_DIR is already in place via the preload; these two only affect code |
| 27 | // that reads them per request, so assigning here is soon enough. |
| 28 | config.DATA_DIR = path.resolve(DATA_DIR); |
| 29 | config.RATE_LIMIT_DISABLED = true; |
| 30 | config.SSH_DISABLED = true; |
| 31 | |
| 32 | /** Bind to port 0 and return the OS-assigned free port number. */ |
| 33 | function getFreePort(): Promise<number> { |
| 34 | return new Promise((resolve, reject) => { |
| 35 | const srv = createServer(); |
| 36 | srv.listen(0, '127.0.0.1', () => { |
| 37 | const port = (srv.address() as { port: number }).port; |
| 38 | srv.close((err) => (err ? reject(err) : resolve(port))); |
| 39 | }); |
| 40 | srv.on('error', reject); |
| 41 | }); |
| 42 | } |
| 43 | |
| 44 | // Resolved once in setupTestEnv() and re-exported for tests. |
| 45 | export let PORT = 0; |
| 46 | export let BASE = ''; |
| 47 | |
| 48 | /** Server handle — an Elysia app instance running in-process. */ |
| 49 | export type ServerHandle = Awaited<ReturnType<typeof createApp>>; |
| 50 | |
| 51 | export async function setupTestEnv() { |
| 52 | PORT = await getFreePort(); |
| 53 | BASE = `http://localhost:${PORT}`; |
| 54 | rmSync(DATA_DIR, { recursive: true, force: true }); |
| 55 | mkdirSync(`${DATA_DIR}/repos`, { recursive: true }); |
| 56 | |
| 57 | nodeSpawnSync('bun', ['run', 'src/db/init.ts'], { |
| 58 | env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS }, |
| 59 | stdio: 'ignore', |
| 60 | }); |
| 61 | |
| 62 | // Reopen DB connection to the freshly-created database file. |
| 63 | resetDb(); |
| 64 | } |
| 65 | |
| 66 | export async function spawnServer(): Promise<ServerHandle> { |
| 67 | return createApp(PORT); |
| 68 | } |
| 69 | |
| 70 | export async function killServer(app: ServerHandle): Promise<void> { |
| 71 | await app.stop(true); |
| 72 | } |
| 73 | |
| 74 | export async function login( |
| 75 | page: Page, |
| 76 | username = 'admin', |
| 77 | password = ADMIN_PASS, |
| 78 | ) { |
| 79 | await page.goto(`${BASE}/login`); |
| 80 | await page.fill('[name=username]', username); |
| 81 | await page.fill('[name=password]', password); |
| 82 | await page.click('button[type=submit]'); |
| 83 | await page.waitForURL(BASE + '/'); |
| 84 | } |
| 85 | |
| 86 | export async function logout(page: Page) { |
| 87 | await page.click('form[action="/logout"] button'); |
| 88 | await page.waitForURL(BASE + '/'); |
| 89 | } |
| 90 | |
| 91 | const git = (args: string[], cwd?: string) => |
| 92 | nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' }); |
| 93 | |
| 94 | export function gitOutput(args: string[], cwd?: string): string { |
| 95 | const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] }); |
| 96 | return r.stdout?.toString().trim() ?? ''; |
| 97 | } |
| 98 | |
| 99 | /** Push an initial commit into a bare repo that already exists on disk. */ |
| 100 | export function seedRepo(name: string) { |
| 101 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 102 | const tmp = `/tmp/hf-seed-${Date.now()}`; |
| 103 | try { |
| 104 | git(['clone', repoPath, tmp]); |
| 105 | git(['config', 'user.email', 'test@test.com'], tmp); |
| 106 | git(['config', 'user.name', 'Test'], tmp); |
| 107 | writeFileSync(`${tmp}/README.md`, `# ${name}\n`); |
| 108 | writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`); |
| 109 | git(['add', '-A'], tmp); |
| 110 | git(['commit', '-m', 'Initial commit'], tmp); |
| 111 | git(['push', 'origin', 'HEAD:main'], tmp); |
| 112 | } finally { |
| 113 | rmSync(tmp, { recursive: true, force: true }); |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | export function writeTempFile(path: string, content: string) { |
| 118 | writeFileSync(path, content); |
| 119 | } |
| 120 | |
| 121 | /** Commit a subdirectory with the given files into an existing repo on main. */ |
| 122 | export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) { |
| 123 | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; |
| 124 | const tmp = `/tmp/hf-subdir-${Date.now()}`; |
| 125 | try { |
| 126 | git(['clone', repoDir, tmp]); |
| 127 | git(['config', 'user.email', 'test@test.com'], tmp); |
| 128 | git(['config', 'user.name', 'Test'], tmp); |
| 129 | mkdirSync(path.join(tmp, dirPath), { recursive: true }); |
| 130 | for (const [fileName, content] of Object.entries(files)) { |
| 131 | writeFileSync(path.join(tmp, dirPath, fileName), content); |
| 132 | } |
| 133 | git(['add', '-A'], tmp); |
| 134 | git(['commit', '-m', `Add ${dirPath}`], tmp); |
| 135 | git(['push', 'origin', 'HEAD:main'], tmp); |
| 136 | } finally { |
| 137 | rmSync(tmp, { recursive: true, force: true }); |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /** Return the HEAD commit hash of a repo. */ |
| 142 | export function getHeadCommit(repoName: string): string { |
| 143 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; |
| 144 | const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { |
| 145 | stdio: ['ignore', 'pipe', 'ignore'], |
| 146 | }); |
| 147 | return result.stdout?.toString().trim() ?? ''; |
| 148 | } |
| 149 | |
| 150 | /** Create a new branch in an existing repo (from current HEAD). */ |
| 151 | export function seedBranch(name: string, branchName: string) { |
| 152 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 153 | const tmp = `/tmp/hf-branch-${Date.now()}`; |
| 154 | try { |
| 155 | git(['clone', repoPath, tmp]); |
| 156 | git(['config', 'user.email', 'test@test.com'], tmp); |
| 157 | git(['config', 'user.name', 'Test'], tmp); |
| 158 | git(['checkout', '-b', branchName], tmp); |
| 159 | git(['push', 'origin', branchName], tmp); |
| 160 | } finally { |
| 161 | rmSync(tmp, { recursive: true, force: true }); |
| 162 | } |
| 163 | } |
| 164 |