SPB Git

Repository Public

helpers.ts 6.1 KB 111 lines typescript

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
Files main
Menu

Theme