// Helpers used by every test. The shape favours the JSON API for // reads + accept/dispatch (deterministic, easy to assert) and the // browser for human-facing surfaces (login form, dashboard render). import { APIRequestContext, expect, Page } from '@playwright/test'; export const baseURL = process.env.RM_BASE_URL ?? 'http://127.0.0.1:8080'; export interface HostJSON { id: string; name: string; status: string; repo_status?: string; last_backup_status?: string; } export async function readBootstrapToken(): Promise { const tok = process.env.RM_BOOTSTRAP_TOKEN; if (!tok) { throw new Error('RM_BOOTSTRAP_TOKEN not set — the harness scrapes it from server logs'); } return tok; } export async function bootstrapAdmin( request: APIRequestContext, { username = 'admin', password = 'e2e-test-password-1234', }: { username?: string; password?: string } = {}, ): Promise<{ username: string; password: string }> { const token = await readBootstrapToken(); const res = await request.post(`${baseURL}/api/bootstrap`, { data: { token, username, password }, }); if (!res.ok() && res.status() !== 409 /* already bootstrapped */) { throw new Error(`bootstrap: ${res.status()} ${await res.text()}`); } return { username, password }; } export async function loginViaUI(page: Page, username: string, password: string): Promise { await page.goto(`${baseURL}/login`); await page.locator('#login-username').fill(username); await page.locator('#login-password').fill(password); await Promise.all([ page.waitForURL(new RegExp(`^${baseURL}/?$`)), page.locator('form[action="/login"] button[type="submit"]').click(), ]); } /** * Polls the dashboard until a pending host card is visible, then * extracts its pending-id from the inline accept form's action URL. */ export async function waitForPendingHostID(page: Page): Promise { const formLocator = page.locator('form[action^="/api/pending-hosts/"][action$="/accept"]').first(); await expect(formLocator).toBeVisible({ timeout: 60_000 }); const action = await formLocator.getAttribute('action'); if (!action) throw new Error('pending host form has no action attribute'); const m = action.match(/\/api\/pending-hosts\/([^/]+)\/accept/); if (!m) throw new Error(`unexpected action URL: ${action}`); return m[1]; } export async function acceptPending( request: APIRequestContext, cookie: string, pendingID: string, repo: { url: string; username?: string; password: string }, ): Promise { const res = await request.post(`${baseURL}/api/pending-hosts/${pendingID}/accept`, { headers: { cookie, 'content-type': 'application/json' }, data: { repo_url: repo.url, repo_username: repo.username ?? '', repo_password: repo.password, }, }); if (!res.ok()) { throw new Error(`accept: ${res.status()} ${await res.text()}`); } } export async function listHosts(request: APIRequestContext, cookie: string): Promise { const res = await request.get(`${baseURL}/api/hosts`, { headers: { cookie } }); if (!res.ok()) throw new Error(`list hosts: ${res.status()} ${await res.text()}`); const body = (await res.json()) as { items?: HostJSON[]; hosts?: HostJSON[] }; return body.items ?? body.hosts ?? []; } export async function waitForHostStatus( request: APIRequestContext, cookie: string, matcher: (h: HostJSON) => boolean, timeoutMs = 60_000, ): Promise { const deadline = Date.now() + timeoutMs; let last: HostJSON | undefined; while (Date.now() < deadline) { const hosts = await listHosts(request, cookie); const hit = hosts.find(matcher); if (hit) return hit; last = hosts[0]; await new Promise((r) => setTimeout(r, 1_000)); } throw new Error(`waitForHostStatus: timeout. Last seen: ${JSON.stringify(last)}`); } export async function createSourceGroup( request: APIRequestContext, cookie: string, hostID: string, body: { name: string; includes: string[]; excludes?: string[] }, ): Promise { const res = await request.post(`${baseURL}/api/hosts/${hostID}/source-groups`, { headers: { cookie, 'content-type': 'application/json' }, data: { name: body.name, includes: body.includes, excludes: body.excludes ?? [], retention_policy: {}, retry_max: 0, retry_backoff_seconds: 0, }, }); if (!res.ok()) throw new Error(`createSourceGroup: ${res.status()} ${await res.text()}`); const created = (await res.json()) as { id?: string; group?: { id?: string } }; const id = created.id ?? created.group?.id; if (!id) throw new Error(`createSourceGroup: no id in response: ${JSON.stringify(created)}`); return id; } export async function runSourceGroup( request: APIRequestContext, cookie: string, hostID: string, groupID: string, ): Promise { const res = await request.post( `${baseURL}/api/hosts/${hostID}/source-groups/${groupID}/run`, { headers: { cookie } }, ); if (!res.ok()) throw new Error(`runSourceGroup: ${res.status()} ${await res.text()}`); } export async function getSessionCookie(page: Page): Promise { const cookies = await page.context().cookies(); const c = cookies.find((c) => c.name === 'rm_session'); if (!c) throw new Error('rm_session cookie not set after login'); return `${c.name}=${c.value}`; }