Files

201 lines
6.4 KiB
JavaScript

/**
* 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();
}