helpers.ts
| 1 | import { $ } from 'bun'; |
| 2 | import { createServer } from 'net'; |
| 3 | import { rmSync, mkdirSync, writeFileSync } from 'fs'; |
| 4 | import path from 'path'; |
| 5 | import type { Page } from 'playwright'; |
| 6 | |
| 7 | export const DATA_DIR = './data-test'; |
| 8 | export const ADMIN_PASS = 'testpass123'; |
| 9 | |
| 10 | /** Bind to port 0 and return the OS-assigned free port number. */ |
| 11 | function getFreePort(): Promise<number> { |
| 12 | return new Promise((resolve, reject) => { |
| 13 | const srv = createServer(); |
| 14 | srv.listen(0, '127.0.0.1', () => { |
| 15 | const port = (srv.address() as { port: number }).port; |
| 16 | srv.close((err) => (err ? reject(err) : resolve(port))); |
| 17 | }); |
| 18 | srv.on('error', reject); |
| 19 | }); |
| 20 | } |
| 21 | |
| 22 | // Resolved once in setupTestEnv() and re-exported for tests. |
| 23 | export let PORT = 0; |
| 24 | export let BASE = ''; |
| 25 | |
| 26 | export async function setupTestEnv() { |
| 27 | PORT = await getFreePort(); |
| 28 | BASE = `http://localhost:${PORT}`; |
| 29 | |
| 30 | rmSync(DATA_DIR, { recursive: true, force: true }); |
| 31 | mkdirSync(`${DATA_DIR}/repos`, { recursive: true }); |
| 32 | |
| 33 | const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], { |
| 34 | env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS }, |
| 35 | stdout: 'pipe', |
| 36 | stderr: 'pipe', |
| 37 | }); |
| 38 | await init.exited; |
| 39 | } |
| 40 | |
| 41 | export function spawnServer() { |
| 42 | return Bun.spawn(['bun', 'run', 'src/index.tsx'], { |
| 43 | env: { |
| 44 | ...process.env, |
| 45 | PORT: String(PORT), |
| 46 | DATA_DIR, |
| 47 | RATE_LIMIT_DISABLED: 'true', |
| 48 | SSH_DISABLED: 'true', |
| 49 | }, |
| 50 | stdout: 'ignore', |
| 51 | stderr: 'ignore', |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | export async function waitForServer(maxMs = 10_000) { |
| 56 | const deadline = Date.now() + maxMs; |
| 57 | while (Date.now() < deadline) { |
| 58 | try { |
| 59 | await fetch(BASE); |
| 60 | return; |
| 61 | } catch { |
| 62 | await Bun.sleep(150); |
| 63 | } |
| 64 | } |
| 65 | throw new Error('Server did not start in time'); |
| 66 | } |
| 67 | |
| 68 | export async function login( |
| 69 | page: Page, |
| 70 | username = 'admin', |
| 71 | password = ADMIN_PASS, |
| 72 | ) { |
| 73 | await page.goto(`${BASE}/login`); |
| 74 | await page.fill('[name=username]', username); |
| 75 | await page.fill('[name=password]', password); |
| 76 | await page.click('button[type=submit]'); |
| 77 | await page.waitForURL(BASE + '/'); |
| 78 | } |
| 79 | |
| 80 | export async function logout(page: Page) { |
| 81 | await page.click('form[action="/logout"] button'); |
| 82 | await page.waitForURL(BASE + '/'); |
| 83 | } |
| 84 | |
| 85 | /** Push an initial commit into a bare repo that already exists on disk. */ |
| 86 | export async function seedRepo(name: string) { |
| 87 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 88 | const tmp = `/tmp/hf-seed-${Date.now()}`; |
| 89 | try { |
| 90 | await $`git clone ${repoPath} ${tmp}`.quiet(); |
| 91 | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); |
| 92 | await $`git -C ${tmp} config user.name "Test"`.quiet(); |
| 93 | writeFileSync(`${tmp}/README.md`, `# ${name}\n`); |
| 94 | writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`); |
| 95 | await $`git -C ${tmp} add -A`.quiet(); |
| 96 | await $`git -C ${tmp} commit -m "Initial commit"`.quiet(); |
| 97 | await $`git -C ${tmp} push origin HEAD:main`.quiet(); |
| 98 | } finally { |
| 99 | await $`rm -rf ${tmp}`.quiet().nothrow(); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | export function writeTempFile(path: string, content: string) { |
| 104 | writeFileSync(path, content); |
| 105 | } |
| 106 | |
| 107 | /** Commit a subdirectory with the given files into an existing repo on main. */ |
| 108 | export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) { |
| 109 | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; |
| 110 | const tmp = `/tmp/hf-subdir-${Date.now()}`; |
| 111 | try { |
| 112 | await $`git clone ${repoDir} ${tmp}`.quiet(); |
| 113 | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); |
| 114 | await $`git -C ${tmp} config user.name "Test"`.quiet(); |
| 115 | mkdirSync(path.join(tmp, dirPath), { recursive: true }); |
| 116 | for (const [fileName, content] of Object.entries(files)) { |
| 117 | writeFileSync(path.join(tmp, dirPath, fileName), content); |
| 118 | } |
| 119 | await $`git -C ${tmp} add -A`.quiet(); |
| 120 | await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet(); |
| 121 | await $`git -C ${tmp} push origin HEAD:main`.quiet(); |
| 122 | } finally { |
| 123 | await $`rm -rf ${tmp}`.quiet().nothrow(); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /** Return the HEAD commit hash of a repo. */ |
| 128 | export async function getHeadCommit(repoName: string): Promise<string> { |
| 129 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; |
| 130 | return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim(); |
| 131 | } |
| 132 | |
| 133 | /** Create a new branch in an existing repo (from current HEAD). */ |
| 134 | export async function seedBranch(name: string, branchName: string) { |
| 135 | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 136 | const tmp = `/tmp/hf-branch-${Date.now()}`; |
| 137 | try { |
| 138 | await $`git clone ${repoPath} ${tmp}`.quiet(); |
| 139 | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); |
| 140 | await $`git -C ${tmp} config user.name "Test"`.quiet(); |
| 141 | await $`git -C ${tmp} checkout -b ${branchName}`.quiet(); |
| 142 | await $`git -C ${tmp} push origin ${branchName}`.quiet(); |
| 143 | } finally { |
| 144 | await $`rm -rf ${tmp}`.quiet().nothrow(); |
| 145 | } |
| 146 | } |
| 147 |