import { test, expect } from '@playwright/test' import { mkdtemp, mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawn, type ChildProcess } from 'node:child_process' import { fileURLToPath } from 'node:url' /** * Browser e2e (spec AC23): timeline renders live SSE events, chat view loads, * and the interface stays keyboard operable. Boots the real service on a * tmpdir with the built frontend. */ const here = fileURLToPath(new URL('.', import.meta.url)) let service: ChildProcess | undefined let dataDir = '' let port = 0 test.beforeAll(async () => { dataDir = await mkdtemp(join(tmpdir(), 'e2e-web-')) const webDist = join(dataDir, 'web-dist') await mkdir(webDist, { recursive: true }) // use the repo build output as static root const repoWebDist = join(here, '..', '..', 'web', 'dist') const repoDocsDist = join(here, '..', '..', 'docs', 'dist') // pick a free port up front so both listeners use known ports const { createServer } = await import('node:http') const probe = createServer() await new Promise((res) => probe.listen(0, '127.0.0.1', () => res())) port = (probe.address() as { port: number }).port await new Promise((res) => probe.close(() => res())) service = spawn( process.execPath, ['--experimental-strip-types', join(here, '..', '..', 'src', 'service', 'main.ts')], { env: { ...process.env, WILL_SERVICE_DATA_DIR: dataDir, WILL_SERVICE_PUBLIC_ADDR: `127.0.0.1:${port}`, WILL_SERVICE_INTERNAL_ADDR: `127.0.0.1:${port + 1}`, WILL_SERVICE_WEB_DIST: repoWebDist, WILL_SERVICE_DOCS_DIST: repoDocsDist, }, stdio: 'ignore', }, ) const deadline = Date.now() + 15_000 for (;;) { try { const res = await fetch(`http://127.0.0.1:${port}/healthz`) if (res.ok) break } catch { // not up yet } if (Date.now() > deadline) throw new Error('service did not become healthy') await new Promise((r) => setTimeout(r, 250)) } }) test.afterAll(() => { service?.kill() }) test('timeline renders live events via SSE', async ({ page }) => { await page.goto(`http://127.0.0.1:${port}/`) await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible() // ingest an event through the internal API and watch it arrive live const intPort = port + 1 await fetch(`http://127.0.0.1:${intPort}/v1/audit/ingest`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ events: [{ id: crypto.randomUUID(), ts: new Date().toISOString(), kind: 'e2e_probe', payload: { hello: 'web' } }], }), }) await expect(page.locator('#timeline-list .event .kind', { hasText: 'e2e_probe' })).toBeVisible() }) test('chat panel lists messages; tabs are keyboard operable', async ({ page }) => { const intPort = port + 1 await fetch(`http://127.0.0.1:${intPort}/v1/chat/ingest`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ messages: [ { id: `tg-${Date.now()}`, chatId: -100, messageId: Math.floor(Math.random() * 100000), direction: 'in', ts: new Date().toISOString(), senderName: 'friend', text: 'browser e2e message', }, ], }), }) await page.goto(`http://127.0.0.1:${port}/#chat`) await expect(page.locator('.chat-msg', { hasText: 'browser e2e message' })).toBeVisible() await page.keyboard.press('Home') await page.locator('#tab-health').focus() await page.keyboard.press('Enter') await expect(page.getByRole('heading', { name: 'Health' })).toBeVisible() await expect(page.locator('#health-facts')).toContainText('storage') // cost view: empty storage renders the zero table, not an error await expect(page.locator('#usage-projection')).toContainText('today') await expect(page.locator('#usage-totals')).toContainText('total') }) test('skip link exists and docs view loads rendered markdown', async ({ page }) => { await page.goto(`http://127.0.0.1:${port}/#docs`) await expect(page.getByRole('link', { name: 'Skip to content' })).toHaveCount(1) await expect(page.locator('#docs-list a').first()).toBeVisible() })