201 lines
6.4 KiB
JavaScript
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();
|
|
}
|