'use strict' const fs = require('fs') const net = require('net') const os = require('os') const path = require('path') const { spawn } = require('child_process') function required(name) { const value = process.env[name] if (!value) throw new Error(`${name} is required`) return value } const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)) async function freePort() { return await new Promise((resolve, reject) => { const server = net.createServer() server.unref() server.on('error', reject) server.listen(0, '127.0.0.1', () => { const port = server.address().port server.close(() => resolve(port)) }) }) } async function retry(operation, label, attempts = 80) { let lastError for (let attempt = 0; attempt < attempts; attempt += 1) { try { return await operation() } catch (error) { lastError = error } await delay(250) } throw new Error(`${label}: ${lastError ? lastError.message : 'timed out'}`) } class PageSession { constructor(socketURL, failures) { this.socket = new WebSocket(socketURL) this.failures = failures this.nextID = 1 this.pending = new Map() } async open() { await new Promise((resolve, reject) => { this.socket.addEventListener('open', resolve, { once: true }) this.socket.addEventListener('error', reject, { once: true }) }) 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 } if (message.method === 'Runtime.exceptionThrown') { this.failures.push(`page: ${message.params.exceptionDetails.text}`) } if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') { const text = message.params.args.map(item => item.value || item.description || '').join(' ') if (!text.includes('favicon')) this.failures.push(`console: ${text}`) } if (message.method === 'Network.responseReceived') { const response = message.params.response if (response.status >= 400 && !response.url.includes('favicon')) { this.failures.push(`http ${response.status}: ${response.url}`) } } }) await Promise.all([ this.send('Page.enable'), this.send('Runtime.enable'), this.send('Network.enable') ]) await this.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }) } 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 })) }) } async evaluate(expression) { const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) if (result.exceptionDetails) throw new Error(result.exceptionDetails.text) return result.result.value } async navigate(url) { await this.send('Page.navigate', { url }) await retry(async () => { if (await this.evaluate('document.readyState') !== 'complete') throw new Error('document not ready') }, `navigate ${url}`) } async waitFor(expression, label) { return await retry(async () => { const value = await this.evaluate(expression) if (!value) throw new Error('condition is false') return value }, label) } close() { this.socket.close() } } async function createPage(debugPort, failures) { const target = await retry(async () => { const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: 'PUT' }) if (!response.ok) throw new Error(`Chrome target returned ${response.status}`) return await response.json() }, 'create Chrome target') const page = new PageSession(target.webSocketDebuggerUrl, failures) await page.open() return page } async function main() { const baseURL = required('SENSE_E2E_BASE_URL') const token = required('SENSE_E2E_TOKEN') const screenshot = required('SENSE_E2E_SCREENSHOT') const debugPort = await freePort() const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'sense-chrome-')) const chrome = spawn(required('SENSE_E2E_BROWSER'), [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, 'about:blank' ], { stdio: 'ignore', windowsHide: true }) const failures = [] try { const anonymous = await createPage(debugPort, failures) await anonymous.navigate(baseURL) await anonymous.waitFor('location.hash.includes("login")', 'anonymous redirect to login') await anonymous.waitFor('!!document.querySelector(\'input[name="username"]\')', 'username input') if (await anonymous.evaluate('[...document.querySelectorAll("input")].some(input => input.placeholder.includes("验证码"))')) { failures.push('login page unexpectedly exposes a captcha input') } anonymous.close() const page = await createPage(debugPort, failures) await page.send('Network.setCookie', { name: 'Sense-Admin-Token', value: token, url: baseURL }) await page.navigate(`${baseURL}/#/dashboard`) await page.waitFor('!!document.querySelector(".sidebar-container")', 'GoAdmin sidebar') await page.waitFor('!!document.querySelector(".navbar")', 'GoAdmin navbar') await page.waitFor('!!document.querySelector(".tags-view-container")', 'GoAdmin tags') await page.evaluate(`(() => { const title = [...document.querySelectorAll('.el-sub-menu__title')] .find(item => item.innerText.includes('视频感知')) if (title) title.click() })()`) await delay(300) const expected = [ ['设备管理', '/sense/device'], ['视频接入', '/sense/admission'], ['视频服务', '/sense/media'], ['实时监看', '/sense/liveview'], ['区域与警戒线', '/sense/area'] ] const sidebarText = await page.evaluate('document.querySelector(".sidebar-container").innerText') for (const [label, route] of expected) { if (!sidebarText.includes(label)) failures.push(`missing menu: ${label}`) const clicked = await page.evaluate(`(() => { const item = [...document.querySelectorAll('.sidebar-container .el-menu-item')] .find(node => node.innerText.trim() === ${JSON.stringify(label)}) if (!item) return false item.click() return true })()`) if (!clicked) failures.push(`menu is not clickable: ${label}`) else await page.waitFor(`location.hash.includes(${JSON.stringify(route)})`, `route ${route}`) if (!await page.evaluate('!!document.querySelector(".sidebar-container")')) { failures.push(`GoAdmin shell disappeared after ${label}`) } } for (const label of ['开发工具', '定时任务', '系统监控']) { if (sidebarText.includes(label)) failures.push(`unrelated menu is visible: ${label}`) } const captured = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }) fs.mkdirSync(path.dirname(screenshot), { recursive: true }) fs.writeFileSync(screenshot, captured.data, 'base64') page.close() } finally { chrome.kill() try { fs.rmSync(profile, { recursive: true, force: true }) } catch {} } if (failures.length) throw new Error([...new Set(failures)].join('\n')) console.log('Sense browser smoke passed: login, GoAdmin shell, five product routes, no unrelated menus.') } main().catch(error => { console.error(error.message) process.exitCode = 1 })