/** * End-to-end smoke test (dependency-free) driving the system Chromium via CDP. * * Prerequisites: * - API running: cd api && php -c php-upload.ini -S localhost:8000 router.php * - Front running: cd partitions && npm run dev * * Run: npm run test:e2e * * Env overrides: API_URL, FRONT_URL, CHROME_PATH, LOGIN_USER, LOGIN_PASS */ import { spawn } from 'node:child_process'; const API = process.env.API_URL || 'http://localhost:8000'; const FRONT = process.env.FRONT_URL || 'http://localhost:5173'; const CHROME = process.env.CHROME_PATH || 'chromium'; const USER = process.env.LOGIN_USER || 'admin'; const PASS = process.env.LOGIN_PASS || 'password'; const CDP_PORT = 9300 + Math.floor(Math.random() * 400); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let passed = 0; let failed = 0; function check(name, cond) { if (cond) { console.log(`✓ ${name}`); passed++; } else { console.log(`✗ ${name}`); failed++; } } async function waitForCdp() { for (let i = 0; i < 60; i++) { try { const list = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json(); const page = list.find((t) => t.type === 'page'); if (page) return page.webSocketDebuggerUrl; } catch { /* retry */ } await sleep(250); } throw new Error('CDP endpoint not available'); } const chrome = spawn( CHROME, [ `--remote-debugging-port=${CDP_PORT}`, '--headless=new', '--no-sandbox', '--disable-gpu', '--window-size=1280,900', `--user-data-dir=/tmp/opencode/e2e-profile-${CDP_PORT}`, 'about:blank' ], { stdio: 'ignore' } ); let ws; try { // 1. Verify servers are up const apiLogin = await fetch(`${API}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: USER, password: PASS }) }).then((r) => r.json()); check('API login succeeds', apiLogin.success === true && !!apiLogin.token); const frontStatus = await fetch(FRONT).then((r) => r.status).catch(() => 0); check('Frontend dev server reachable', frontStatus === 200); // 2. Connect to Chromium ws = new WebSocket(await waitForCdp()); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); let id = 0; const pending = new Map(); const apiResponses = []; const downloadRequests = []; ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id).res(m.result); pending.delete(m.id); } else if (m.method === 'Network.responseReceived' && m.params.response.url.startsWith(API)) { apiResponses.push({ status: m.params.response.status, url: m.params.response.url }); } else if (m.method === 'Network.requestWillBeSent' && m.params.request.url.includes('/download/')) { downloadRequests.push({ url: m.params.request.url, method: m.params.request.method, headers: m.params.request.headers }); } }; const send = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, { res }); ws.send(JSON.stringify({ id: i, method, params })); }); await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); // 3. Login through the API then inject the session (same as the app does) await send('Page.navigate', { url: `${FRONT}/` }); await sleep(2500); await send('Runtime.evaluate', { expression: `localStorage.setItem('auth', ${JSON.stringify(JSON.stringify({ token: apiLogin.token, user: apiLogin.user }))})` }); async function visit(path, expectation) { apiResponses.length = 0; await send('Page.navigate', { url: FRONT + path }); await sleep(3500); const body = await send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true }); const text = body.result.value || ''; const bad = apiResponses.filter((r) => r.status >= 400); check(`${path}: no API error`, bad.length === 0); check(`${path}: ${expectation.label}`, expectation.test(text)); const pathname = await send('Runtime.evaluate', { expression: 'window.location.pathname', returnByValue: true }); check(`${path}: stays on page`, pathname.result.value === path); return text; } // 4. Public pages await visit('/scores', { label: 'renders repertoire', test: (t) => t.includes('Répertoire') }); await visit('/admin', { label: 'renders admin', test: (t) => t.includes('Administration') }); // 5. Pick a real score/instrument through the API and open the viewer const token = apiLogin.token; const auth = { Authorization: `Bearer ${token}` }; const scores = await fetch(`${API}/scores`, { headers: auth }).then((r) => r.json()); const candidates = scores.scores || []; check('API returns at least one score', candidates.length > 0); // Find a score that actually has an instrument with files (skip empty ones) let chosen = null; for (const score of candidates.slice(0, 25)) { const instr = await fetch(`${API}/scores/${score.id}/instruments`, { headers: auth }).then((r) => r.json()); const instrument = (instr.instruments || []).find((i) => i.parts?.[0]?.files?.length); if (instrument) { chosen = { scoreId: score.id, instrument }; break; } } check('API returns a playable score/instrument', !!chosen); if (chosen) { await visit(`/scores/${chosen.scoreId}`, { label: 'renders score detail', test: (t) => t.length > 0 }); const viewerPath = `/scores/${chosen.scoreId}/${chosen.instrument.piece}/${chosen.instrument.id}`; downloadRequests.length = 0; await visit(viewerPath, { label: 'renders instrument', test: (t) => t.length > 0 }); check('Viewer fetches the PDF', downloadRequests.some((r) => r.method === 'GET')); const getDownloads = downloadRequests.filter((r) => r.method === 'GET'); const hasAuthHeader = getDownloads.every((r) => Object.keys(r.headers).some((k) => k.toLowerCase() === 'authorization') ); check( 'PDF download uses Authorization header', hasAuthHeader && getDownloads.every((r) => !r.url.includes('token=')) ); await visit(`/admin/${chosen.scoreId}`, { label: 'renders admin detail', test: (t) => t.includes('Informations de la partition') }); } console.log(`\n=== E2E Summary ===\nPassed: ${passed}\nFailed: ${failed}\nTotal: ${passed + failed}`); if (failed > 0) { console.log('\n⚠ Some E2E checks failed!'); process.exitCode = 1; } else { console.log('\n✓ All E2E checks passed!'); } } catch (err) { console.error('E2E error:', err.message || err); process.exitCode = 1; } finally { if (ws) ws.close(); chrome.kill(); }