Files
yovision/Sense/tests/e2e/browser-smoke.cjs

204 lines
7.7 KiB
JavaScript

'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
})