Files
multi-simadmin/scripts/real-browser-e2e.mjs
T

407 lines
14 KiB
JavaScript

#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { createServer as createHttpServer } from 'node:http';
import { tmpdir } from 'node:os';
import { extname, join } from 'node:path';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
const { WebSocket } = globalThis;
const DIST = fileURLToPath(new URL('../apps/web/dist/', import.meta.url));
const CLEAN_DIST = process.argv.includes('--clean-dist');
const FORBIDDEN_PORT = 8788;
const CHROME_CANDIDATES = [
process.env.CHROME_BIN,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
].filter(Boolean);
const EMPTY_PAGE = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
async function builtAssetHandler(request, response) {
const pathname = new URL(request.url ?? '/', 'http://e2e.local').pathname;
if (pathname === '/favicon.ico' || pathname === '/api/v1/events') {
response.statusCode = 204;
response.end();
return;
}
let body;
if (pathname === '/api/v1/instances') body = EMPTY_PAGE;
if (pathname === '/api/v1/jobs' || pathname === '/api/v1/audit') body = EMPTY_PAGE;
if (body !== undefined) {
response.statusCode = 200;
response.setHeader('content-type', 'application/json; charset=utf-8');
response.end(JSON.stringify(body));
return;
}
if (pathname === '/api' || pathname.startsWith('/api/')) {
response.statusCode = 404;
response.setHeader('content-type', 'application/json; charset=utf-8');
response.end(JSON.stringify({ error: 'Not Found', statusCode: 404 }));
return;
}
const relative = pathname.startsWith('/assets/') ? pathname.slice(1) : 'index.html';
try {
const asset = await readFile(join(DIST, relative));
response.statusCode = 200;
response.setHeader(
'content-type',
{
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
}[extname(relative)] ?? 'application/octet-stream',
);
response.end(asset);
} catch {
response.statusCode = 500;
response.end('Built web assets are missing. Run the web build before this gate.');
}
}
async function listenOnSafeEphemeralPort(server) {
for (let attempt = 0; attempt < 10; attempt += 1) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
assert(address && typeof address === 'object');
if (address.port !== FORBIDDEN_PORT) return address.port;
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
throw new Error(
`OS repeatedly selected forbidden legacy port ${FORBIDDEN_PORT} for the E2E server.`,
);
}
async function executableChrome() {
const { access, constants } = await import('node:fs/promises');
for (const candidate of CHROME_CANDIDATES) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
// Continue through known local browser locations.
}
}
throw new Error(
`No executable Chrome/Chromium found. Set CHROME_BIN to run this real-browser gate.`,
);
}
async function waitForFile(pathname, child) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null)
throw new Error(`Chrome exited before CDP became ready (${child.exitCode}).`);
try {
return await readFile(pathname, 'utf8');
} catch {
await delay(50);
}
}
throw new Error('Timed out waiting for Chrome DevToolsActivePort.');
}
async function waitForPageTarget(port) {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
const targets = await response.json();
const page = targets.find((target) => target.type === 'page');
if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
} catch {
// Chrome may expose the port just before the target endpoint is ready.
}
await delay(50);
}
throw new Error('Timed out waiting for a Chrome page target.');
}
class CdpClient {
constructor(url) {
this.nextId = 1;
this.pending = new Map();
this.listeners = new Map();
this.socket = new WebSocket(url);
this.socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.id) {
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (message.error) pending.reject(new Error(message.error.message));
else pending.resolve(message.result);
return;
}
for (const listener of this.listeners.get(message.method) ?? [])
listener(message.params ?? {});
});
}
async open() {
if (this.socket.readyState === WebSocket.OPEN) return;
await new Promise((resolve, reject) => {
this.socket.addEventListener('open', resolve, { once: true });
this.socket.addEventListener('error', () => reject(new Error('CDP WebSocket failed.')), {
once: true,
});
});
}
send(method, params = {}) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.send(JSON.stringify({ id, method, params }));
});
}
once(method, timeout = 10_000) {
return new Promise((resolve, reject) => {
const listener = (params) => {
clearTimeout(timer);
this.listeners.set(
method,
(this.listeners.get(method) ?? []).filter((candidate) => candidate !== listener),
);
resolve(params);
};
this.listeners.set(method, [...(this.listeners.get(method) ?? []), listener]);
const timer = setTimeout(() => {
this.listeners.set(
method,
(this.listeners.get(method) ?? []).filter((candidate) => candidate !== listener),
);
reject(new Error(`Timed out waiting for CDP ${method}.`));
}, timeout);
});
}
on(method, listener) {
this.listeners.set(method, [...(this.listeners.get(method) ?? []), listener]);
}
async close() {
if (this.socket.readyState === WebSocket.CLOSED) return;
const closed = new Promise((resolve) =>
this.socket.addEventListener('close', resolve, { once: true }),
);
this.socket.close();
await Promise.race([closed, delay(2_000)]);
}
}
async function stopChrome(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
if ((await Promise.race([exited.then(() => true), delay(5_000, false)])) === true) return;
child.kill('SIGKILL');
if ((await Promise.race([exited.then(() => true), delay(2_000, false)])) !== true) {
throw new Error('Chrome did not exit after SIGTERM and SIGKILL.');
}
}
async function evaluate(cdp, expression) {
const result = await cdp.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (result.exceptionDetails)
throw new Error(result.exceptionDetails.text ?? 'Browser evaluation failed.');
return result.result.value;
}
async function eventually(cdp, expression, message) {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
const value = await evaluate(cdp, expression);
if (value) return value;
} catch {
// A full-page navigation briefly destroys the execution context.
}
await delay(50);
}
throw new Error(message);
}
async function press(cdp, key, code, windowsVirtualKeyCode, text) {
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key,
code,
windowsVirtualKeyCode,
text,
});
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode });
}
async function keyboardNavigate(cdp, label, expectedPath, expectedHeading) {
const focused = await evaluate(
cdp,
`(() => { const link = [...document.querySelectorAll('nav[aria-label="Global navigation"] a')].find((item) => item.textContent.trim() === ${JSON.stringify(label)}); if (!link) return false; link.focus(); return document.activeElement === link; })()`,
);
assert.equal(focused, true, `${label} navigation link must accept keyboard focus`);
await press(cdp, 'Enter', 'Enter', 13, '\r');
await eventually(
cdp,
`location.pathname === ${JSON.stringify(expectedPath)}`,
`${label} keyboard navigation failed`,
);
await eventually(
cdp,
`document.querySelector('h1')?.textContent.trim() === ${JSON.stringify(expectedHeading)}`,
`${label} heading did not render`,
);
assert.equal(
await evaluate(cdp, `document.querySelector('nav a[aria-current="page"]')?.textContent.trim()`),
label,
);
}
let http;
let chrome;
let cdp;
let profile;
try {
const chromeBinary = await executableChrome();
http = createHttpServer((request, response) => void builtAssetHandler(request, response));
const serverPort = await listenOnSafeEphemeralPort(http);
const origin = `http://127.0.0.1:${serverPort}`;
profile = await mkdtemp(join(tmpdir(), 'multi-simadmin-chrome-'));
chrome = spawn(
chromeBinary,
[
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--remote-debugging-port=0',
`--user-data-dir=${profile}`,
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
let chromeStderr = '';
chrome.stderr.on('data', (chunk) => {
chromeStderr += chunk.toString();
});
const activePort = await waitForFile(join(profile, 'DevToolsActivePort'), chrome);
const port = Number(activePort.split(/\r?\n/u)[0]);
assert(Number.isInteger(port) && port > 0, 'Chrome did not publish a valid CDP port');
cdp = new CdpClient(await waitForPageTarget(port));
await cdp.open();
const failures = [];
cdp.on('Runtime.exceptionThrown', ({ exceptionDetails }) =>
failures.push(`uncaught exception: ${exceptionDetails?.text ?? 'unknown'}`),
);
cdp.on('Runtime.consoleAPICalled', ({ type, args }) => {
if (type === 'error' || type === 'assert')
failures.push(
`console.${type}: ${args?.map((arg) => arg.value ?? arg.description).join(' ')}`,
);
});
cdp.on('Log.entryAdded', ({ entry }) => {
if (entry?.level === 'error') failures.push(`browser log: ${entry.text}`);
});
await Promise.all([cdp.send('Page.enable'), cdp.send('Runtime.enable'), cdp.send('Log.enable')]);
assert.equal(
await evaluate(cdp, 'location.href'),
'about:blank',
'Chrome must attach at about:blank before app navigation',
);
const unknownApi = await fetch(`${origin}/api/v1/not-a-real-route`);
assert.equal(unknownApi.status, 404, 'Unknown API routes must not receive the SPA shell');
assert.match(unknownApi.headers.get('content-type') ?? '', /^application\/json\b/u);
assert.deepEqual(await unknownApi.json(), { error: 'Not Found', statusCode: 404 });
const loaded = cdp.once('Page.loadEventFired');
await cdp.send('Page.navigate', { url: `${origin}/fleet` });
await loaded;
await eventually(
cdp,
`document.querySelector('h1')?.textContent.trim() === 'Fleet'`,
'Fleet did not render',
);
await eventually(
cdp,
`document.body.textContent.includes('No instances are configured.')`,
'Fleet mock data did not load',
);
assert.equal(
await evaluate(
cdp,
`(() => { const input = document.querySelector('input[type="search"]'); input.focus(); return document.activeElement === input; })()`,
),
true,
'Fleet search must accept focus',
);
for (const character of 'edge')
await press(
cdp,
character,
`Key${character.toUpperCase()}`,
character.toUpperCase().charCodeAt(0),
character,
);
assert.equal(await evaluate(cdp, `document.querySelector('input[type="search"]').value`), 'edge');
await keyboardNavigate(cdp, 'Jobs', '/jobs', 'Jobs');
await eventually(
cdp,
`document.body.textContent.includes('No jobs match the current query.')`,
'Jobs mock data did not load',
);
await keyboardNavigate(cdp, 'Audit', '/audit', 'Audit');
await eventually(
cdp,
`document.body.textContent.includes('No audit events match the current query.')`,
'Audit mock data did not load',
);
await keyboardNavigate(cdp, 'Settings', '/settings/instances', 'Instances');
await eventually(
cdp,
`document.body.textContent.includes('No instances are configured.')`,
'Settings mock data did not load',
);
assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`);
console.log(`PASS real Chrome E2E (${chromeBinary})`);
console.log(`PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`);
console.log(
'PASS keyboard navigation and rendered API states: /fleet -> /jobs -> /audit -> /settings/instances',
);
} catch (error) {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
} finally {
try {
await cdp?.close();
await stopChrome(chrome);
} finally {
if (http) {
http.closeAllConnections?.();
await new Promise((resolve) => http.close(resolve));
}
if (profile) await rm(profile, { recursive: true, force: true });
if (CLEAN_DIST) await rm(DIST, { recursive: true, force: true });
}
}