Repository Public
1import { createHmac } from 'node:crypto';2import { expect, type Locator, type Page, type ConsoleMessage } from '@playwright/test';34export const SEED = { username: 'admin', password: 'correct-horse-battery', totpSecret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' };5export const MOCK = `http://127.0.0.1:${process.env.MOCK_PORT ?? 8641}/api/v1`;6/** Seeded fixture missions of dev/mock-server.mjs. */7export const FIX = { approval: 'Fix the rounding error in invoice totals', question: 'Document the public invoice API', running: 'Speed up the tax table lookup', done: 'Add tax-inclusive totals to the invoice API', failed: 'Regenerate the API reference' };8export const ROUTES = ['/home/', '/missions/', '/approvals/', '/projects/', '/policy/', '/spend/', '/system/', '/settings/'];910export function totp(secret: string, t = Date.now()): string {11 const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';12 let bits = '';13 for (const c of secret) bits += B32.indexOf(c).toString(2).padStart(5, '0');14 const key = Buffer.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));15 const counter = Buffer.alloc(8);16 counter.writeBigUInt64BE(BigInt(Math.floor(t / 30000)));17 const h = createHmac('sha1', key).update(counter).digest();18 const o = h[h.length - 1]! & 0xf;19 return String((h.readUInt32BE(o) & 0x7fffffff) % 1e6).padStart(6, '0');20}2122/** Collects console errors, page errors and CSP violations; assert with `expectClean()`. */23export function watch(page: Page): { errors: string[]; expectClean: () => void } {24 const errors: string[] = [];25 page.on('console', (m: ConsoleMessage) => {26 const t = m.text();27 if (m.type() !== 'error') return;28 // Expected: 401 on /auth/* before sign-in, deliberate 4xx exercised by the tests, sockets dropped on purpose.29 if (/Failed to load resource: the server responded with a status of (401|404|409|429|403)/.test(t)) return; // 404s are tracked by URL below30 if (/WebSocket connection to .* failed/.test(t)) return;31 errors.push(t);32 });33 page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));34 page.on('response', (r) => {35 if (r.status() === 404 && !r.url().includes('/api/')) errors.push(`404 ${r.url()}`);36 });37 // CSP violations are reported by the browser as console errors; also record the event itself.38 void page.addInitScript(() => {39 document.addEventListener('securitypolicyviolation', (e) => console.error(`CSP violation: ${e.effectiveDirective} ${e.blockedURI}`));40 });41 return { errors, expectClean: () => expect(errors, 'console errors / CSP violations').toEqual([]) };42}4344/** Sign in through the API (the browser context shares the cookie jar). */45export async function apiLogin(page: Page): Promise<void> {46 const r = await page.request.post('/api/v1/auth/login', { data: { username: SEED.username, password: SEED.password, totp: totp(SEED.totpSecret) } });47 expect(r.ok()).toBeTruthy();48}49export async function csrf(page: Page): Promise<string> {50 return (await (await page.request.get('/api/v1/auth/status')).json()).csrfToken as string;51}52export async function apiGet<T = unknown>(page: Page, path: string): Promise<T> {53 const r = await page.request.get(`/api/v1${path}`);54 expect(r.ok(), `${path} → ${r.status()}`).toBeTruthy();55 return (await r.json()) as T;56}57export async function apiPost<T = unknown>(page: Page, path: string, data: unknown = {}): Promise<T> {58 const r = await page.request.post(`/api/v1${path}`, { data, headers: { 'X-CSRF-Token': await csrf(page) } });59 expect(r.ok(), `${path} → ${r.status()}`).toBeTruthy();60 return (await r.json()) as T;61}62export async function resetMock(page: Page, seed = true): Promise<void> {63 await page.request.post(`${MOCK}/__mock/reset`, { data: { seed } });64}65export interface MockMission { id: string; title: string; status: string; rootRunId: string }66export async function mission(page: Page, title: string): Promise<MockMission> {67 const info = (await (await page.request.get(`${MOCK}/__mock/info`)).json()) as { missions: MockMission[] };68 const m = info.missions.find((x) => x.title === title);69 expect(m, `fixture mission "${title}"`).toBeTruthy();70 return m!;71}72export async function seeded(page: Page): Promise<void> {73 await resetMock(page);74 await apiLogin(page);75}76/** Theme for screenshots: stored on the server (it follows the owner) and locally (applied before paint). */77export async function setTheme(page: Page, theme: 'light' | 'dark'): Promise<void> {78 const r = await page.request.put('/api/v1/settings', { data: { theme }, headers: { 'X-CSRF-Token': await csrf(page) } });79 expect(r.ok()).toBeTruthy();80 await page.addInitScript((t) => localStorage.setItem('aos.theme', t), theme);81}82export const shot = (page: Page, name: string) => page.screenshot({ path: `e2e/screenshots/${name}.png` });8384declare global {85 interface Window {86 __aos: { dropEvents(): void; cursor(): number | null; seqs(runId: string): number[] | null };87 }88}89/** Journal seqs known to the browser for a run (E2E build only). */90export const clientSeqs = (page: Page, runId: string) => page.evaluate((id) => window.__aos.seqs(id), runId);91export async function serverSeqs(page: Page, runId: string): Promise<number[]> {92 return (await apiGet<{ seq: number }[]>(page, `/runs/${runId}/steps?limit=500`)).map((s) => s.seq);93}94/**95 * The timeline is virtualised: a row far from the viewport is not in the DOM. Scrolls from the top,96 * a screen at a time, until the row exists, then brings it into view.97 */98export async function reveal(page: Page, row: Locator): Promise<void> {99 const scroller = page.getByTestId('timeline-scroll');100 await scroller.evaluate((el) => { el.scrollTop = 0; });101 for (let i = 0; i < 80; i++) {102 if ((await row.count()) > 0) break;103 const moved = await scroller.evaluate((el) => { const before = el.scrollTop; el.scrollTop += el.clientHeight * 0.7; return el.scrollTop > before; });104 await page.waitForTimeout(60);105 if (!moved && (await row.count()) === 0) break;106 }107 await row.first().scrollIntoViewIfNeeded();108}109/** Keys of the rendered timeline rows: must be unique (no duplicated item). */110export const rowKeys = (page: Page) => page.locator('[data-testid="timeline"] [data-key]').evaluateAll((els) => els.map((e) => e.getAttribute('data-key') ?? ''));111