204 lines
7.7 KiB
JavaScript
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
|
|
})
|