#!/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'; import { parseExternalE2eOrigin } from './real-browser-e2e-origin.mjs'; 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 EXTERNAL_ORIGIN = parseExternalE2eOrigin(process.env.E2E_ORIGIN); 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(); let origin = EXTERNAL_ORIGIN; if (!origin) { http = createHttpServer((request, response) => void builtAssetHandler(request, response)); const serverPort = await listenOnSafeEphemeralPort(http); 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'); const unknownApiContentType = unknownApi.headers.get('content-type') ?? ''; assert.doesNotMatch(unknownApiContentType, /^text\/html\b/u); assert.match(unknownApiContentType, /^application\/(?:problem\+)?json\b/u); const unknownApiProblem = await unknownApi.json(); if (EXTERNAL_ORIGIN) { assert( unknownApiProblem && typeof unknownApiProblem === 'object' && (unknownApiProblem.status === 404 || unknownApiProblem.statusCode === 404 || typeof unknownApiProblem.title === 'string' || typeof unknownApiProblem.type === 'string'), 'Unknown external API route must return a JSON Problem Details object', ); } else { assert.deepEqual(unknownApiProblem, { 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( EXTERNAL_ORIGIN ? `PASS live same-origin canary on ${origin} (legacy port 8788 untouched)` : `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 }); } }