#!/usr/bin/env node import { spawn } from 'node:child_process'; import { createRequire } from 'node:module'; import { TextEncoder } from 'node:util'; import { chromium } from 'playwright'; import { setTimeout as delay } from 'node:timers/promises'; const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/'; const shouldStartPreview = !process.env.FRONTEND_URL; const doFetch = globalThis.fetch; const verifyMember = `Verify ${process.pid}`; const apiBase = process.env.BACKEND_URL ? process.env.BACKEND_URL.replace(/\/$/, '') : process.env.FRONTEND_URL ? new URL(process.env.FRONTEND_URL).origin : 'http://localhost:8787'; const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({ DATA_TOPIC: 'podman.intervention', })); const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url)); const { Room } = backendRequire('@livekit/rtc-node'); async function stopChild(child) { if (!child || child.exitCode !== null || child.signalCode !== null) return; try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); } await Promise.race([ new Promise((resolve) => child.once('exit', resolve)), delay(3000).then(() => { if (child.exitCode === null && child.signalCode === null) { try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } } }), ]); } async function waitForPreview() { for (let i = 0; i < 50; i++) { try { const res = await doFetch(frontendUrl); if (res.ok) return; } catch { // Preview is still starting. } await delay(200); } throw new Error(`frontend preview did not become ready at ${frontendUrl}`); } async function fetchJson(path, init) { const res = await doFetch(`${apiBase}${path}`, init); const text = await res.text(); const body = text ? JSON.parse(text) : null; if (!res.ok) throw new Error(`${path} returned ${res.status}: ${text}`); return body; } async function connectPublisher(roomName) { const { token, url } = await fetchJson('/api/token', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room: roomName, identity: `verify-agent-${process.pid}`, name: 'PodMan', }), }); const room = new Room(); await room.connect(url, token); return room; } async function publishIntervention(room, podId) { const now = new Date().toISOString(); const id = `verify-${process.pid}-${Date.now()}`; const intervention = { id: `int-${id}`, collisionId: `col-${id}`, podId, kind: 'card', message: 'Verification collision: two engineers are editing frontend/src/App.tsx.', suggestedAction: { kind: 'sync_before_push' }, status: 'pending', createdAt: now, }; const message = { type: 'COLLISION', collision: { id: intervention.collisionId, podId, file: 'src/App.tsx', engineers: ['Verify', 'PodMan'], severity: 'warn', githubState: { branch: 'verify', unpushed: true, prs: [] }, detectedAt: now, }, intervention, }; await room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: DATA_TOPIC, }); } async function waitForInterventionCard(page, room, podId) { const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.'; for (let attempt = 1; attempt <= 3; attempt++) { await publishIntervention(room, podId); try { await page.getByText(cardText).waitFor({ timeout: 5_000 }); return; } catch (error) { if (attempt === 3) throw error; await delay(500); } } } let preview = null; if (shouldStartPreview) { preview = spawn( 'pnpm', ['--filter', '@podman/frontend', 'preview', '--host', '127.0.0.1', '--port', '4173'], { detached: true, stdio: ['ignore', 'pipe', 'pipe'], }, ); await waitForPreview(); } const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); await page.addInitScript(() => { Object.defineProperty(globalThis.navigator, 'mediaDevices', { configurable: true, value: { ...(globalThis.navigator.mediaDevices ?? {}), async getDisplayMedia() { const canvas = globalThis.document.createElement('canvas'); canvas.width = 640; canvas.height = 360; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas context unavailable'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#111'; ctx.font = '28px sans-serif'; ctx.fillText('PodMan verification screen', 32, 72); return canvas.captureStream(5); }, }, }); }); const consoleErrors = []; const pageErrors = []; const failedRequests = []; page.on('console', (msg) => { if (msg.type() !== 'error') return; const text = msg.text(); if (text.includes('net::ERR_NETWORK_CHANGED')) return; consoleErrors.push(text); }); page.on('pageerror', (err) => pageErrors.push(err.message)); page.on('requestfailed', (req) => { failedRequests.push(`${req.url()} ${req.failure()?.errorText ?? ''}`.trim()); }); try { await page.goto(frontendUrl, { waitUntil: 'networkidle', timeout: 30_000 }); await page.waitForTimeout(500); const bodyText = await page.locator('body').innerText(); const hasPodCards = (await page.locator('text=/Frontend Pod|Backend Pod|graph pod/i').count()) > 0; const hasOverlay = (await page.locator('vite-error-overlay, .vite-error-overlay').count()) > 0; if (bodyText.length < 100) throw new Error('frontend rendered too little text'); if (!hasPodCards) throw new Error('pod cards did not render'); if (hasOverlay) throw new Error('Vite error overlay is visible'); await page.getByRole('button', { name: 'Team memory' }).click(); await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 }); await page.getByText('Learning edges').waitFor({ timeout: 15_000 }); await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 }); await page.getByRole('button', { name: 'engineer: Karti' }).click(); await page.getByText('Learned owner of auth; backend + DB wiring.').waitFor({ timeout: 15_000 }); await page.getByRole('button', { name: 'Whole graph' }).click(); await page.getByRole('button', { name: /Pods/i }).click(); const frontendPodCard = page .getByText('Frontend Pod', { exact: true }) .locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]'); await frontendPodCard.getByPlaceholder('Your name').fill(verifyMember); await frontendPodCard.getByRole('button', { name: 'Add and join' }).click(); await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 }); const joinedText = await page.locator('body').innerText(); const hasPodView = joinedText.includes('Leave pod') && joinedText.includes('Share screen') && (joinedText.includes('Test audio') || joinedText.includes('Play beat')); if (!hasPodView) { throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`); } await page.getByRole('button', { name: 'Share screen' }).click(); await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 }); await page.getByText(/Screen\s*published/i).waitFor({ timeout: 15_000 }); await page.getByRole('button', { name: 'Stop sharing' }).click(); await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 }); const publisher = await connectPublisher('frontend-pod'); try { await waitForInterventionCard(page, publisher, 'frontend-pod'); await page.getByRole('button', { name: 'Dismiss' }).click(); await page.getByText('No collision detected').waitFor({ timeout: 15_000 }); } finally { await publisher.disconnect(); } await page.getByRole('button', { name: 'Leave pod' }).click(); if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`); if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`); if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`); console.log( JSON.stringify( { ok: true, frontendUrl, bodyLength: bodyText.length, graph: true, joined: true, screenShare: true, intervention: true, member: verifyMember, }, null, 2, ), ); } finally { await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, { method: 'DELETE', }).catch(() => {}); await browser.close(); await stopChild(preview); } process.exit(0);