Repository Public
1import { createHmac } from 'node:crypto';2import { expect, 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 ?? 8621}/api/v1`;67export function totp(secret: string, t = Date.now()): string {8 const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';9 let bits = '';10 for (const c of secret) bits += B32.indexOf(c).toString(2).padStart(5, '0');11 const key = Buffer.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));12 const counter = Buffer.alloc(8);13 counter.writeBigUInt64BE(BigInt(Math.floor(t / 30000)));14 const h = createHmac('sha1', key).update(counter).digest();15 const o = h[h.length - 1]! & 0xf;16 return String((h.readUInt32BE(o) & 0x7fffffff) % 1e6).padStart(6, '0');17}1819/** Collects console errors, page errors and CSP violations; assert with `expectClean()`. */20export function watch(page: Page): { errors: string[]; expectClean: () => void } {21 const errors: string[] = [];22 page.on('console', (m: ConsoleMessage) => {23 const t = m.text();24 if (m.type() === 'warning' && /Could not create web worker/.test(t)) errors.push(t); // Monaco fell back to the main thread25 if (m.type() !== 'error') return;26 // Expected: 401 on /auth/* before sign-in, deliberate 409/413/415/429 exercised by the tests, dropped sockets.27 if (/Failed to load resource: the server responded with a status of (401|404|409|413|415|429|403)/.test(t)) return; // 404s are tracked by URL below28 if (/WebSocket connection to .* failed/.test(t)) return;29 errors.push(t);30 });31 page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));32 page.on('response', (r) => {33 if (r.status() === 404 && !r.url().includes('/api/')) errors.push(`404 ${r.url()}`);34 });35 // CSP violations are reported by the browser as console errors; also record the event itself.36 void page.addInitScript(() => {37 document.addEventListener('securitypolicyviolation', (e) => console.error(`CSP violation: ${e.effectiveDirective} ${e.blockedURI}`));38 });39 return { errors, expectClean: () => expect(errors, 'console errors / CSP violations').toEqual([]) };40}4142/** Sign in through the API (the browser context shares the cookie jar). */43export async function apiLogin(page: Page): Promise<void> {44 const r = await page.request.post('/api/v1/auth/login', { data: { username: SEED.username, password: SEED.password, totp: totp(SEED.totpSecret) } });45 expect(r.ok()).toBeTruthy();46}47export async function csrf(page: Page): Promise<string> {48 return (await (await page.request.get('/api/v1/auth/status')).json()).csrfToken as string;49}50export async function apiPost<T = unknown>(page: Page, path: string, data: unknown = {}): Promise<T> {51 const r = await page.request.post(`/api/v1${path}`, { data, headers: { 'X-CSRF-Token': await csrf(page) } });52 expect(r.ok(), `${path} → ${r.status()}`).toBeTruthy();53 return (await r.json()) as T;54}55export async function demoProjectId(page: Page): Promise<string> {56 const list = (await (await page.request.get('/api/v1/projects')).json()) as { id: string; name: string }[];57 return list.find((p) => p.name === 'demo')!.id;58}59export async function resetMock(page: Page, seed = true): Promise<void> {60 await page.request.post(`${MOCK}/__mock/reset`, { data: { seed } });61}6263interface CrTerm {64 ids(): string[];65 text(id: string): string | null;66 status(id: string): string | null;67 send(id: string, data: string): void;68 dropSocket(id: string): void;69 sent(id: string): string[] | null;70 size(id: string): { cols: number; rows: number; fontSize: number } | null;71 hasFocus(id: string): boolean;72 scroll(id: string): { viewportY: number; baseY: number; type: string } | null;73}74declare global {75 interface Window {76 __crTerm: CrTerm;77 }78}79export const termText = (page: Page, id: string) => page.evaluate((i) => window.__crTerm?.text(i) ?? '', id);80export const termStatus = (page: Page, id: string) => page.evaluate((i) => window.__crTerm?.status(i) ?? null, id);81export async function waitTermOpen(page: Page, id: string): Promise<void> {82 await expect.poll(() => termStatus(page, id), { timeout: 15_000 }).toBe('open');83 await expect.poll(() => termText(page, id)).toContain('mock$');84}85export async function expectTerm(page: Page, id: string, text: string | RegExp): Promise<void> {86 await expect.poll(() => termText(page, id), { timeout: 15_000 }).toMatch(typeof text === 'string' ? new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : text);87}88/** Create a shell session through the API and open it in the terminal page. */89export async function openShell(page: Page, title = 'e2e shell'): Promise<string> {90 const s = await apiPost<{ id: string }>(page, '/sessions', { projectId: await demoProjectId(page), kind: 'shell', title });91 await page.goto(`/terminal/?s=${s.id}`);92 await waitTermOpen(page, s.id);93 return s.id;94}95