#!/usr/bin/env node import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdir, mkdtemp, readFile, rm, writeFile } 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 SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR?.trim(); const FLEET_FIXTURE = process.env.E2E_FLEET_FIXTURE !== '0'; const requestPathnames = []; const CHROME_CANDIDATES = [ process.env.CHROME_BIN, 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', '/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 } }; const SCHEDULE_FIXTURE = { id: 'morning-restart', name: '每日服务重启', operationType: 'restart-service', cronExpression: '0 4 * * *', timezone: 'Asia/Shanghai', targetSelector: { mode: 'tags', match: 'all', tags: ['核心', '5G'] }, misfirePolicy: 'skip', overlapPolicy: 'skip', retryPolicy: { maxRetries: 1, intervalSeconds: 60 }, enabled: true, version: 3, createdBy: 'operator', updatedBy: 'operator', createdAt: '2026-07-30T00:00:00.000Z', updatedAt: '2026-07-30T00:00:00.000Z', nextDueAt: '2026-07-30T20:00:00.000Z', }; const FLEET_FIXTURE_ITEMS = [ { id: 'edge-north', name: 'Edge North', origin: 'http://10.0.0.21', tags: ['核心', '5G'], revision: 4, }, { id: 'lab-backup', name: 'Lab Backup', origin: 'http://10.0.0.22', tags: ['备用'], revision: 2 }, { id: 'harbour-gateway', name: 'Harbour Gateway', origin: 'http://10.0.0.23', tags: ['香港', '公网'], revision: 7, }, { id: 'field-unit', name: 'Field Unit 07', origin: 'http://10.0.0.24', tags: ['外场'], revision: 3, }, ]; async function builtAssetHandler(request, response) { const pathname = new URL(request.url ?? '/', 'http://e2e.local').pathname; requestPathnames.push(pathname); if (pathname === '/favicon.ico') { response.statusCode = 204; response.end(); return; } if (pathname === '/api/v1/events') { response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', }); response.flushHeaders(); return; } let body; if (pathname === '/api/v1/auth/status') body = { configured: false, protectionEnabled: false, authenticated: false }; if (pathname === '/api/v1/instances') body = FLEET_FIXTURE ? { items: FLEET_FIXTURE_ITEMS, page: { page: 1, pageSize: 25, total: FLEET_FIXTURE_ITEMS.length }, } : EMPTY_PAGE; if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/resources$/u.test(pathname)) { const fixtureIndex = FLEET_FIXTURE_ITEMS.findIndex((item) => pathname.includes(item.id)); body = { version: `2.4.${Math.max(0, fixtureIndex)}`, cpuPercent: [24, 61, 38, 76][Math.max(0, fixtureIndex)], memoryPercent: [52, 43, 68, 71][Math.max(0, fixtureIndex)], maxTemperatureCelsius: [42, 47, 44, 58][Math.max(0, fixtureIndex)], phoneNumbers: [`+852 5550 10${Math.max(0, fixtureIndex)}`], }; } if (FLEET_FIXTURE && /^\/api\/v1\/instances\/[^/]+\/messages$/u.test(pathname)) { const fixtureIndex = FLEET_FIXTURE_ITEMS.findIndex((item) => pathname.includes(item.id)); body = { messages: [ { id: `sms-${Math.max(0, fixtureIndex)}`, direction: 'incoming', phoneNumber: `1390013900${Math.max(0, fixtureIndex)}`, content: 'status', timestamp: '2026-07-30T08:30:00.000Z', }, ], }; } if ( pathname === '/api/v1/jobs' || pathname === '/api/v1/audit' || pathname === '/api/v1/automation/runs' ) body = EMPTY_PAGE; if (pathname === '/api/v1/automation/schedules') body = { items: [SCHEDULE_FIXTURE], page: { page: 1, pageSize: 25, total: 1 } }; if (pathname === '/api/v1/automation/cron/preview') body = { occurrences: [ '2026-07-31T01:00:00.000Z', '2026-08-01T01:00:00.000Z', '2026-08-02T01:00:00.000Z', '2026-08-03T01:00:00.000Z', '2026-08-04T01:00:00.000Z', ], }; 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 === '/favicon.svg' ? 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', '.svg': 'image/svg+xml; 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="全局导航"] 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, ); } async function captureScreenshot(cdp, filename) { if (!SCREENSHOT_DIR) return; await mkdir(SCREENSHOT_DIR, { recursive: true }); const result = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false, }); await writeFile(join(SCREENSHOT_DIR, filename), Buffer.from(result.data, 'base64')); } async function navigate(cdp, url) { const loaded = cdp.once('Page.loadEventFired'); await cdp.send('Page.navigate', { url }); await loaded; } async function clickButton(cdp, label) { const clicked = await evaluate( cdp, `(() => { const button = [...document.querySelectorAll('button')].find((item) => item.textContent.trim() === ${JSON.stringify(label)}); if (!button) return false; button.click(); return true; })()`, ); assert.equal(clicked, true, `Button not found: ${label}`); } async function fleetLayout(cdp) { return evaluate( cdp, `(() => { const grid = document.querySelector('.fleet-card-grid'); const workspace = document.querySelector('.fleet-workspace'); const sidebar = document.querySelector('.fleet-sidebar'); const sidebarHeader = document.querySelector('.fleet-sidebar-header'); const results = document.querySelector('.fleet-results-pane'); const groups = document.querySelector('.fleet-group-tabs'); const firstCard = document.querySelector('.fleet-card'); const version = document.querySelector('.fleet-card-version'); const firstResource = document.querySelector('.fleet-resource-row'); const telemetry = document.querySelector('.fleet-card-telemetry'); const footer = document.querySelector('.fleet-card-footer'); const footerNumber = document.querySelector('.fleet-card-footer-primary > span:last-child'); const menuTrigger = document.querySelector('.fleet-card-menu-trigger'); const menuPanel = document.querySelector('.fleet-card-menu-panel'); const checkbox = document.querySelector('.fleet-card-select'); const hardware = document.querySelector('.fleet-card-hardware'); const phoneField = document.querySelector('.fleet-card-phone'); const phoneValue = document.querySelector('.fleet-card-phone-value'); const phoneToggle = document.querySelector('.fleet-phone-privacy-toggle'); const temperatureField = document.querySelector('.fleet-card-hardware > div:not(.fleet-card-phone)'); const bounds = (element) => { if (!element) return null; const rect = element.getBoundingClientRect(); return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, width: rect.width, height: rect.height }; }; const hardwareEntries = [...document.querySelectorAll('.fleet-card-hardware > div')].map((item) => { const label = item.querySelector('dt'); const value = item.querySelector('.fleet-card-phone-value') ?? item.querySelector('dd'); return { labelHeight: label?.getBoundingClientRect().height ?? 0, labelFits: label ? label.scrollWidth <= label.clientWidth : false, valueHeight: value?.getBoundingClientRect().height ?? 0, valueFits: value ? value.scrollWidth <= value.clientWidth : false, valueClientWidth: value?.clientWidth ?? 0, valueScrollWidth: value?.scrollWidth ?? 0, }; }); const navigation = [...document.querySelectorAll('nav[aria-label="全局导航"] a')].map((item) => { const rect = item.getBoundingClientRect(); return { text: item.textContent.trim(), left: rect.left, right: rect.right }; }); return { viewport: window.innerWidth, scrollWidth: document.documentElement.scrollWidth, columns: grid ? getComputedStyle(grid).gridTemplateColumns.split(' ').length : 0, groupDisplay: groups ? getComputedStyle(groups).display : null, resourceDisplay: firstResource ? getComputedStyle(firstResource).display : null, telemetryDisplay: telemetry ? getComputedStyle(telemetry).display : null, footerDisplay: footer ? getComputedStyle(footer).display : null, workspaceDisplay: workspace ? getComputedStyle(workspace).display : null, sidebarHeaderDisplay: sidebarHeader ? getComputedStyle(sidebarHeader).display : null, sidebar: bounds(sidebar), results: bounds(results), groups: bounds(groups), firstCard: bounds(firstCard), version: bounds(version), versionText: version?.textContent.trim() ?? null, versionFits: version ? version.scrollWidth <= version.clientWidth : false, firstResource: bounds(firstResource), telemetry: bounds(telemetry), footer: bounds(footer), footerNumber: bounds(footerNumber), footerNumberFits: footerNumber ? footerNumber.scrollWidth <= footerNumber.clientWidth : false, menuTrigger: bounds(menuTrigger), menuPanel: bounds(menuPanel), checkbox: bounds(checkbox), hardware: bounds(hardware), phoneField: bounds(phoneField), phoneValue: bounds(phoneValue), phoneToggle: bounds(phoneToggle), temperatureField: bounds(temperatureField), hardwareEntries, navigation, }; })()`, ); } async function pageLayout(cdp) { return evaluate( cdp, `(() => { const layout = document.querySelector('.app-layout'); if (!layout) return null; const rect = layout.getBoundingClientRect(); const style = getComputedStyle(layout); const paddingLeft = Number.parseFloat(style.paddingLeft); const paddingRight = Number.parseFloat(style.paddingRight); return { viewport: document.documentElement.clientWidth, left: rect.left + paddingLeft, right: rect.right - paddingRight, width: rect.width - paddingLeft - paddingRight }; })()`, ); } async function assertWidePageGutters(cdp, label) { const layout = await pageLayout(cdp); assert.ok(layout, `${label} application layout must render`); const rightGap = layout.viewport - layout.right; assert.equal( layout.left >= 16 && layout.left <= 24 && rightGap >= 16 && rightGap <= 24, true, `${label} must keep 16px to 24px outer gutters: ${JSON.stringify(layout)}`, ); } 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 viewportCases = [ { width: 390, height: 844, columns: 1, sidebarMode: 'stacked', mobile: true }, { width: 768, height: 900, columns: 2, sidebarMode: 'stacked', mobile: false }, { width: 1024, height: 900, columns: 2, sidebarMode: 'left', mobile: false }, { width: 1440, height: 1000, columns: 4, sidebarMode: 'left', mobile: false }, { width: 1920, height: 1080, columns: 5, sidebarMode: 'left', mobile: false }, ]; for (const viewport of viewportCases) { await cdp.send('Emulation.setDeviceMetricsOverride', { width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: viewport.mobile, }); await navigate(cdp, `${origin}/fleet`); await eventually( cdp, `document.querySelector('h1')?.textContent.trim() === '节点'`, `Fleet did not render at ${viewport.width}px`, ); await eventually( cdp, `document.querySelectorAll('[role="article"]').length === ${FLEET_FIXTURE_ITEMS.length}`, `Fleet mock data did not load at ${viewport.width}px`, ); await eventually( cdp, `document.querySelector('.fleet-card-version')?.textContent.trim() === 'SimAdmin 2.4.0'`, `Fleet SimAdmin version did not synchronize at ${viewport.width}px`, ); await eventually( cdp, `document.querySelector('.fleet-card-footer-primary')?.textContent.includes('139 •••• 9000')`, `Fleet SMS phone did not load privately at ${viewport.width}px`, ); if (viewport.width === 390) { const branding = await evaluate( cdp, `(() => ({ title: document.title, favicon: new URL(document.querySelector('link[rel="icon"]').href).pathname, theme: document.querySelector('meta[name="theme-color"]').content }))()`, ); assert.deepEqual(branding, { title: 'SimAdmin Control · 多节点控制台', favicon: '/favicon.svg', theme: '#f3f0e7', }); } const privacyText = await evaluate( cdp, `(() => ({ text: document.body.textContent, toggleCount: document.querySelectorAll('.fleet-phone-privacy-toggle').length }))()`, ); assert.equal(privacyText.toggleCount, FLEET_FIXTURE_ITEMS.length); assert.match(privacyText.text, /\+852 •••• 0100/u); assert.match(privacyText.text, /139 •••• 9000/u); assert.doesNotMatch(privacyText.text, /\+852 5550 100/u); assert.doesNotMatch(privacyText.text, /13900139000/u); const layout = await fleetLayout(cdp); assert.equal( layout.scrollWidth <= layout.viewport, true, `${viewport.width}px layout overflows: ${JSON.stringify(layout)}`, ); assert.equal(layout.columns, viewport.columns, `${viewport.width}px Fleet column count`); assert.equal(layout.groupDisplay, 'flex', `${viewport.width}px tag groups must use flex`); assert.equal(layout.resourceDisplay, 'grid', `${viewport.width}px resource rows must use grid`); assert.equal(layout.telemetryDisplay, 'grid', `${viewport.width}px telemetry must use grid`); assert.equal( layout.footerDisplay, viewport.width >= 1280 ? 'grid' : 'flex', `${viewport.width}px SMS footer layout`, ); assert.equal( layout.workspaceDisplay, 'grid', `${viewport.width}px Fleet workspace must use the responsive grid`, ); assert.notEqual( layout.sidebarHeaderDisplay, 'none', `${viewport.width}px Fleet sidebar heading must remain visible`, ); assert.ok(layout.sidebar && layout.results, `${viewport.width}px Fleet panes must render`); assert.ok( layout.groups && layout.firstCard && layout.version && layout.firstResource && layout.telemetry && layout.footer && layout.footerNumber && layout.menuTrigger && layout.hardware && layout.phoneField && layout.phoneValue && layout.phoneToggle && layout.temperatureField, `${viewport.width}px Fleet information hierarchy must render`, ); for (const [name, bounds] of [ ['tag groups', layout.groups], ['first card', layout.firstCard], ]) { assert.equal( bounds.left >= layout.results.left - 0.5 && bounds.right <= layout.results.right + 0.5, true, `${viewport.width}px ${name} leaves results pane: ${JSON.stringify({ bounds, results: layout.results })}`, ); } assert.equal( layout.versionText, 'SimAdmin 2.4.0', `${viewport.width}px card must render the upstream SimAdmin version`, ); assert.equal( layout.versionFits, true, `${viewport.width}px SimAdmin version must remain fully visible`, ); assert.equal( layout.firstResource.left >= layout.firstCard.left - 0.5 && layout.firstResource.right <= layout.firstCard.right + 0.5, true, `${viewport.width}px resource row leaves card: ${JSON.stringify(layout)}`, ); for (const [name, bounds] of [ ['SimAdmin version', layout.version], ['telemetry', layout.telemetry], ['SMS footer', layout.footer], ['action menu trigger', layout.menuTrigger], ]) { assert.equal( bounds.left >= layout.firstCard.left - 0.5 && bounds.right <= layout.firstCard.right + 0.5, true, `${viewport.width}px ${name} leaves card: ${JSON.stringify(layout)}`, ); } assert.equal( layout.menuTrigger.width >= 40 && layout.menuTrigger.height >= 40, true, `${viewport.width}px card action trigger is too small: ${JSON.stringify(layout.menuTrigger)}`, ); assert.equal( layout.footerNumberFits, true, `${viewport.width}px latest SMS phone must remain fully visible`, ); assert.equal( Math.abs(layout.phoneToggle.width - 40) <= 0.5 && Math.abs(layout.phoneToggle.height - 40) <= 0.5, true, `${viewport.width}px privacy toggle must remain 40px: ${JSON.stringify(layout.phoneToggle)}`, ); assert.equal( layout.phoneField.left >= layout.hardware.left - 0.5 && layout.temperatureField.right <= layout.hardware.right + 0.5 && layout.phoneValue.right <= layout.phoneToggle.left + 0.5 && layout.phoneToggle.right <= layout.phoneField.right + 0.5 && layout.phoneField.right < layout.temperatureField.left, true, `${viewport.width}px phone and temperature geometry overlaps: ${JSON.stringify(layout)}`, ); assert.equal( layout.hardwareEntries.length === FLEET_FIXTURE_ITEMS.length * 2 && layout.hardwareEntries.every((entry) => entry.labelFits && entry.valueFits), true, `${viewport.width}px hardware facts overflow: ${JSON.stringify(layout.hardwareEntries)}`, ); if (viewport.sidebarMode === 'left') { assert.equal( layout.sidebar.right < layout.results.left, true, `${viewport.width}px Fleet sidebar must remain left of results: ${JSON.stringify(layout)}`, ); } else { assert.equal( layout.sidebar.bottom < layout.results.top, true, `${viewport.width}px Fleet sidebar must stack above results: ${JSON.stringify(layout)}`, ); assert.equal( layout.sidebar.left <= layout.results.left && layout.sidebar.right >= layout.results.right, true, `${viewport.width}px stacked Fleet sidebar must span the results width: ${JSON.stringify(layout)}`, ); } assert.equal(layout.navigation.length, 3, 'Global navigation must have three entries'); assert.deepEqual( layout.navigation.map((item) => item.text), ['节点', '自动化', '设置'], ); assert.equal( layout.navigation.every((item) => item.left >= 0 && item.right <= layout.viewport), true, `${viewport.width}px navigation leaves viewport: ${JSON.stringify(layout.navigation)}`, ); assert.equal( await evaluate(cdp, `document.querySelectorAll('.fleet-card-select input').length`), 0, 'Fleet checkboxes must be hidden outside batch mode', ); await captureScreenshot(cdp, `warm-fleet-${viewport.width}.png`); if (viewport.width === 390) { await evaluate( cdp, `(() => { const card = document.querySelector('.fleet-card'); window.scrollBy({ top: card.getBoundingClientRect().top - 128 }); })()`, ); await delay(200); await captureScreenshot(cdp, 'warm-fleet-390-cards.png'); } if (viewport.width === 1440) { await evaluate(cdp, `document.querySelector('.fleet-phone-privacy-toggle').click()`); await eventually( cdp, `document.querySelector('.fleet-card')?.textContent.includes('+852 5550 100') && document.querySelector('.fleet-card')?.textContent.includes('13900139000')`, 'Fleet phone disclosure did not reveal both phone locations', ); const disclosedText = await evaluate( cdp, `[...document.querySelectorAll('.fleet-card')].map((card) => card.textContent)`, ); assert.match(disclosedText[0], /\+852 5550 100/u); assert.match(disclosedText[0], /13900139000/u); assert.doesNotMatch(disclosedText[1], /\+852 5550/u); assert.doesNotMatch(disclosedText[1], /1390013900/u); await evaluate(cdp, `document.querySelector('.fleet-phone-privacy-toggle').click()`); await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`); await eventually( cdp, `document.querySelector('.fleet-card-menu-panel') !== null`, 'Fleet card action menu did not open', ); const menuLayout = await fleetLayout(cdp); assert.ok(menuLayout.menuPanel, 'Fleet card action menu geometry missing'); assert.equal( menuLayout.menuPanel.left >= menuLayout.firstCard.left - 0.5 && menuLayout.menuPanel.right <= menuLayout.firstCard.right + 0.5 && menuLayout.menuPanel.right <= menuLayout.viewport + 0.5, true, `Fleet card action menu leaves its card or viewport: ${JSON.stringify(menuLayout)}`, ); await captureScreenshot(cdp, 'warm-fleet-1440-menu.png'); await evaluate(cdp, `document.querySelector('.fleet-card-menu-trigger').click()`); } if (viewport.width === 390) { await clickButton(cdp, '批量选择'); await eventually( cdp, `document.querySelectorAll('.fleet-card-select input').length === ${FLEET_FIXTURE_ITEMS.length}`, 'Mobile Fleet checkboxes did not appear in batch mode', ); const mobileSelectionLayout = await fleetLayout(cdp); assert.ok( mobileSelectionLayout.checkbox && mobileSelectionLayout.firstCard, 'Mobile Fleet selection geometry missing', ); assert.equal( mobileSelectionLayout.checkbox.left > mobileSelectionLayout.firstCard.left + mobileSelectionLayout.firstCard.width / 2, true, `Mobile Fleet checkbox must stay in the card upper-right: ${JSON.stringify(mobileSelectionLayout)}`, ); assert.equal( mobileSelectionLayout.checkbox.top < mobileSelectionLayout.firstCard.top + mobileSelectionLayout.firstCard.height / 3, true, `Mobile Fleet checkbox must stay in the card header: ${JSON.stringify(mobileSelectionLayout)}`, ); } } assert.equal( await evaluate( cdp, `(() => { const input = document.querySelector('input[type="search"]'); input.focus(); return document.activeElement === input; })()`, ), true, 'Fleet search must accept focus', ); await clickButton(cdp, '批量选择'); await eventually( cdp, `document.querySelectorAll('.fleet-card-select input').length === ${FLEET_FIXTURE_ITEMS.length}`, 'Fleet checkboxes did not appear in batch mode', ); await evaluate(cdp, `document.querySelector('.fleet-card-select input').click()`); await eventually( cdp, `document.querySelector('.fleet-card-select input').checked === true`, 'Fleet selection did not update', ); const selectionLayout = await fleetLayout(cdp); assert.ok( selectionLayout.checkbox && selectionLayout.firstCard, 'Fleet selection geometry missing', ); assert.equal( selectionLayout.checkbox.left > selectionLayout.firstCard.left + selectionLayout.firstCard.width / 2, true, `Fleet checkbox must stay in the card upper-right: ${JSON.stringify(selectionLayout)}`, ); assert.equal( selectionLayout.checkbox.top < selectionLayout.firstCard.top + selectionLayout.firstCard.height / 3, true, `Fleet checkbox must stay in the card header: ${JSON.stringify(selectionLayout)}`, ); await assertWidePageGutters(cdp, 'Nodes'); await eventually( cdp, `document.querySelector('.connection-status [data-state="open"]') !== null`, 'Event stream did not reach the open state', ); await evaluate(cdp, `window.__navigationLifetime = crypto.randomUUID()`); const navigationLifetime = await evaluate(cdp, `window.__navigationLifetime`); const requestCountsBeforeSettings = EXTERNAL_ORIGIN ? undefined : { events: requestPathnames.filter((pathname) => pathname === '/api/v1/events').length, fleet: requestPathnames.filter( (pathname) => pathname === '/api/v1/instances' || /^\/api\/v1\/instances\/[^/]+\/resources$/u.test(pathname), ).length, }; await keyboardNavigate(cdp, '设置', '/settings/system', '密码保护'); assert.equal( await evaluate(cdp, `window.__navigationLifetime`), navigationLifetime, 'Settings navigation reloaded the document', ); assert.equal( await evaluate(cdp, `document.activeElement?.id`), 'main-content', 'Settings navigation must focus the main content', ); if (requestCountsBeforeSettings) { await delay(100); assert.equal( requestPathnames.filter((pathname) => pathname === '/api/v1/events').length, requestCountsBeforeSettings.events, 'Settings navigation recreated the event subscription', ); assert.equal( requestPathnames.filter( (pathname) => pathname === '/api/v1/instances' || /^\/api\/v1\/instances\/[^/]+\/resources$/u.test(pathname), ).length, requestCountsBeforeSettings.fleet, 'Settings navigation requested Fleet data', ); } await assertWidePageGutters(cdp, 'Settings'); await keyboardNavigate(cdp, '自动化', '/automation', '自动化'); await assertWidePageGutters(cdp, 'Automation'); assert.deepEqual( await evaluate( cdp, `[...document.querySelectorAll('[role="tab"]')].map((item) => item.textContent.trim())`, ), ['计划任务', '执行记录', '操作审计'], ); await eventually( cdp, `document.querySelectorAll('.row-action-trigger').length === 1 && document.querySelector('.row-actions') === null`, 'Schedule row menu trigger did not render', ); const createButtonStyle = await evaluate( cdp, `(() => { const button = [...document.querySelectorAll('button')].find((item) => item.textContent.trim() === '创建任务'); const style = getComputedStyle(button); return { color: style.color, backgroundColor: style.backgroundColor, opacity: style.opacity }; })()`, ); assert.equal( createButtonStyle.backgroundColor, 'rgb(23, 143, 132)', `Create button lost its primary fill: ${JSON.stringify(createButtonStyle)}`, ); const shortPageGeometry = await evaluate( cdp, `(() => ({ footerBottom: document.querySelector('.app-footer').getBoundingClientRect().bottom, viewportBottom: innerHeight }))()`, ); assert.equal( Math.abs(shortPageGeometry.footerBottom - shortPageGeometry.viewportBottom) <= 1, true, `Short-page footer is not anchored to the viewport: ${JSON.stringify(shortPageGeometry)}`, ); const scheduleTableGeometry = await evaluate( cdp, `(() => { const table = document.querySelector('.schedule-table-wrap'); return { clientHeight: table.clientHeight, scrollHeight: table.scrollHeight, overflowY: getComputedStyle(table).overflowY }; })()`, ); assert.equal( scheduleTableGeometry.overflowY, 'hidden', `Schedule table has an unnecessary vertical scrollbar: ${JSON.stringify(scheduleTableGeometry)}`, ); await evaluate(cdp, `document.querySelector('.row-action-trigger').click()`); await eventually( cdp, `document.querySelectorAll('.row-actions button').length === 4`, 'Schedule row menu actions did not render', ); await captureScreenshot(cdp, 'warm-automation-1440.png'); await evaluate(cdp, `document.querySelector('.row-action-trigger').click()`); await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true, }); await clickButton(cdp, '创建任务'); await eventually( cdp, `document.querySelector('[role="dialog"]') !== null`, 'Schedule drawer did not open', ); await eventually( cdp, `(() => { const rect = document.querySelector('[role="dialog"]').getBoundingClientRect(); return rect.left >= 0 && rect.right <= innerWidth + 0.5; })()`, 'Schedule drawer did not settle inside the viewport', ); const drawerLayout = await evaluate( cdp, `(() => { const dialog = document.querySelector('[role="dialog"]'); const rect = dialog.getBoundingClientRect(); return { viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth, left: rect.left, right: rect.right }; })()`, ); assert.equal( drawerLayout.scrollWidth <= drawerLayout.viewport, true, 'Schedule drawer overflows', ); assert.equal( drawerLayout.left >= 0 && drawerLayout.right <= drawerLayout.viewport + 0.5, true, `Schedule drawer leaves viewport: ${JSON.stringify(drawerLayout)}`, ); await captureScreenshot(cdp, 'warm-automation-drawer-390.png'); await navigate(cdp, `${origin}/jobs`); await eventually( cdp, `document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '执行记录'`, '/jobs alias did not select execution history', ); await navigate(cdp, `${origin}/audit`); await eventually( cdp, `document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === '操作审计'`, '/audit alias did not select operation audit', ); await navigate(cdp, `${origin}/settings/instances`); await eventually( cdp, `location.pathname === '/settings/system'`, 'Legacy settings route did not replace the browser pathname', ); await eventually( cdp, `document.querySelector('h1')?.textContent.trim() === '密码保护'`, 'Legacy settings route did not render password protection', ); 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 persistent navigation, live SSE status, Automation workflows, and 390/768/1024/1440/1920 responsive layouts', ); } 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, maxRetries: 8, retryDelay: 125 }); if (CLEAN_DIST) await rm(DIST, { recursive: true, force: true }); } }