1076 lines
41 KiB
JavaScript
1076 lines
41 KiB
JavaScript
#!/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 });
|
|
}
|
|
}
|