Files
user-system/frontend/admin/scripts/run-cdp-smoke.mjs

1625 lines
52 KiB
JavaScript
Raw Normal View History

import { mkdtemp, readdir, rm, access, mkdir } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import net from 'node:net'
const TEXT = {
appTitle: '\u7528\u6237\u7ba1\u7406\u7cfb\u7edf',
welcomeLogin: '\u6b22\u8fce\u767b\u5f55',
loginAction: '\u767b\u5f55',
passwordLogin: '\u5bc6\u7801\u767b\u5f55',
emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801',
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801',
usernamePlaceholder: '\u7528\u6237\u540d',
passwordPlaceholder: '\u5bc6\u7801',
emailPlaceholder: '\u90ae\u7bb1',
phonePlaceholder: '\u624b\u673a',
codePlaceholder: '\u9a8c\u8bc1\u7801',
dashboard: '\u603b\u89c8',
users: '\u7528\u6237\u7ba1\u7406',
roles: '\u89d2\u8272\u7ba1\u7406',
logout: '\u9000\u51fa\u767b\u5f55',
usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7',
rolesFilter: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801',
userDetail: '\u7528\u6237\u8be6\u60c5',
assignRoles: '\u5206\u914d\u89d2\u8272',
assignPermissions: '\u5206\u914d\u6743\u9650',
assignableRoles: '\u53ef\u5206\u914d\u89d2\u8272',
assignedRoles: '\u5df2\u5206\u914d\u89d2\u8272',
createRole: '\u521b\u5efa\u89d2\u8272',
roleNameCode: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801',
adminRoleName: '\u7ba1\u7406\u5458',
userId: '\u7528\u6237 ID',
totalUsers: '\u7528\u6237\u603b\u6570',
todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55',
permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650',
}
const DEFAULT_BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000'
const DEFAULT_LOGIN_PATH = process.env.E2E_LOGIN_PATH ?? '/login'
const BASE_URL = new URL(DEFAULT_BASE_URL).toString().replace(/\/$/, '')
const LOGIN_URL = new URL(DEFAULT_LOGIN_PATH, `${BASE_URL}/`).toString()
const DASHBOARD_URL = new URL('/dashboard', `${BASE_URL}/`).toString()
const USERS_URL = new URL('/users', `${BASE_URL}/`).toString()
const LOGIN_USERNAME = (process.env.E2E_LOGIN_USERNAME ?? '').trim()
const LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD ?? ''
const EXTERNAL_BROWSER = process.env.E2E_SKIP_BROWSER_LAUNCH === '1'
const EXTERNAL_CDP_PORT = Number(process.env.E2E_CDP_PORT ?? 0)
const EXTERNAL_CDP_JSON_URL = process.env.E2E_CDP_JSON_URL ?? ''
const EXTERNAL_CDP_BASE_URL = process.env.E2E_CDP_BASE_URL ?? ''
const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 30000)
const ASSERT_TIMEOUT_MS = Number(process.env.E2E_ASSERT_TIMEOUT_MS ?? 15000)
const NAVIGATION_TIMEOUT_MS = Number(process.env.E2E_NAVIGATION_TIMEOUT_MS ?? 15000)
const COMMAND_TIMEOUT_MS = Number(process.env.E2E_COMMAND_TIMEOUT_MS ?? 120000)
const DEBUG = process.env.E2E_DEBUG === '1'
async function main() {
let browserPath
let port
let profileDir
let browser
let connection
try {
await waitForHttp(`${BASE_URL}/`, STARTUP_TIMEOUT_MS, 'frontend dev server')
let cdpBaseUrl
if (EXTERNAL_BROWSER) {
cdpBaseUrl = resolveExternalCdpBaseUrl()
} else {
browserPath = await resolveBrowserPath()
port = await getFreePort()
profileDir = await createBrowserProfileDir(browserPath, port)
browser = startBrowser(browserPath, port, profileDir)
cdpBaseUrl = `http://127.0.0.1:${port}`
}
logDebug(`connecting to ${cdpBaseUrl}`)
const version = await waitForJson(`${cdpBaseUrl}/json/version`, STARTUP_TIMEOUT_MS)
let summary
for (let attempt = 1; attempt <= 2; attempt++) {
const pageTarget = await getOrCreatePageTarget(cdpBaseUrl, version, STARTUP_TIMEOUT_MS)
connection = await CDPConnection.connect(pageTarget.webSocketDebuggerUrl)
try {
logDebug(`running smoke attempt ${attempt}`)
summary = await runSmoke(connection, {
loginUrl: LOGIN_URL,
dashboardUrl: DASHBOARD_URL,
usersUrl: USERS_URL,
assertTimeoutMs: ASSERT_TIMEOUT_MS,
navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
browserVersion: version.Browser ?? version.product ?? 'unknown',
loginUsername: LOGIN_USERNAME,
loginPassword: LOGIN_PASSWORD,
})
break
} catch (error) {
const shouldRetry = attempt < 2 && isRetryableCDPError(error)
await connection?.close().catch(() => {})
connection = null
if (!shouldRetry) {
throw error
}
logDebug(`retrying smoke after transient CDP failure: ${formatError(error)}`)
await delay(1000)
}
}
printSummary(summary)
} finally {
logDebug('closing connection')
await connection?.close().catch(() => {})
if (browser) {
await killBrowserTree(browser)
}
if (profileDir) {
await rm(profileDir, { recursive: true, force: true }).catch(() => {})
}
}
}
function resolveExternalCdpBaseUrl() {
if (EXTERNAL_CDP_BASE_URL) {
return EXTERNAL_CDP_BASE_URL.replace(/\/$/, '')
}
if (EXTERNAL_CDP_JSON_URL) {
return new URL(EXTERNAL_CDP_JSON_URL).origin
}
if (EXTERNAL_CDP_PORT > 0) {
return `http://127.0.0.1:${EXTERNAL_CDP_PORT}`
}
throw new Error(
'external browser mode requires E2E_CDP_PORT, E2E_CDP_BASE_URL, or E2E_CDP_JSON_URL',
)
}
function startBrowser(browserPath, port, profileDir) {
const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox']
if (isHeadlessShell(browserPath)) {
args.push('--single-process')
} else {
args.push(
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--headless=new',
)
}
args.push('about:blank')
const browser = spawn(browserPath, args, {
stdio: 'ignore',
windowsHide: true,
})
browser.on('exit', (code, signal) => {
if (code !== 0 && signal == null) {
console.error(`browser exited unexpectedly with code ${code}`)
}
})
return browser
}
async function createBrowserProfileDir(browserPath, port) {
if (!isHeadlessShell(browserPath)) {
return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-'))
}
const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles')
await mkdir(profileRoot, { recursive: true })
return path.join(profileRoot, `pw-profile-cdp-smoke-node-${port}`)
}
async function resolveBrowserPath() {
const envPath =
process.env.CHROME_HEADLESS_SHELL_PATH ??
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
if (envPath) {
await assertFileExists(envPath)
return envPath
}
for (const candidate of [
'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',
]) {
try {
await assertFileExists(candidate)
return candidate
} catch {
continue
}
}
const baseDir = path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright')
const candidates = []
try {
const entries = await readdir(baseDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
if (!entry.name.startsWith('chromium_headless_shell-')) {
continue
}
candidates.push(
path.join(
baseDir,
entry.name,
'chrome-headless-shell-win64',
'chrome-headless-shell.exe',
),
)
}
} catch {
throw new Error('failed to scan Playwright browser cache under LOCALAPPDATA')
}
candidates.sort().reverse()
for (const candidate of candidates) {
try {
await assertFileExists(candidate)
return candidate
} catch {
continue
}
}
throw new Error('chrome-headless-shell.exe not found; set CHROME_HEADLESS_SHELL_PATH')
}
async function assertFileExists(filePath) {
await access(filePath, fsConstants.F_OK)
}
function isHeadlessShell(browserPath) {
return path.basename(browserPath).toLowerCase().includes('headless-shell')
}
async function killBrowserTree(browser) {
if (browser.exitCode != null || browser.pid == null) {
return
}
await new Promise((resolve) => {
const killer = spawn('taskkill', ['/PID', String(browser.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
})
killer.once('error', () => {
try {
browser.kill('SIGKILL')
} catch {
// ignore
}
resolve()
})
killer.once('exit', () => resolve())
})
}
async function getFreePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer()
server.unref()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (address == null || typeof address === 'string') {
server.close(() => reject(new Error('failed to resolve a free port')))
return
}
server.close((error) => {
if (error) {
reject(error)
return
}
resolve(address.port)
})
})
})
}
async function waitForHttp(url, timeoutMs, label) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url)
if (response.ok) {
return response
}
lastError = new Error(`${label} returned ${response.status}`)
} catch (error) {
lastError = error
}
await delay(250)
}
throw new Error(`timed out waiting for ${label}: ${formatError(lastError)}`)
}
async function waitForJson(url, timeoutMs) {
const response = await waitForHttp(url, timeoutMs, url)
return await response.json()
}
async function waitForPageTarget(cdpBaseUrl, timeoutMs) {
const startedAt = Date.now()
let lastTargets = []
while (Date.now() - startedAt < timeoutMs) {
try {
const targets = await waitForJson(`${cdpBaseUrl}/json/list`, 5000)
lastTargets = Array.isArray(targets) ? targets : []
const pageTarget = lastTargets.find(
(target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string',
)
if (pageTarget) {
return pageTarget
}
} catch {
// retry
}
await delay(250)
}
throw new Error(`timed out waiting for page target: ${JSON.stringify(lastTargets)}`)
}
async function getOrCreatePageTarget(cdpBaseUrl, version, timeoutMs) {
try {
return await waitForPageTarget(cdpBaseUrl, Math.min(timeoutMs, 5000))
} catch (firstError) {
const browserWsUrl = version?.webSocketDebuggerUrl
if (typeof browserWsUrl !== 'string' || browserWsUrl.length === 0) {
throw firstError
}
await createPageTarget(browserWsUrl)
return await waitForPageTarget(cdpBaseUrl, timeoutMs)
}
}
async function createPageTarget(browserWsUrl) {
const browserConnection = await CDPConnection.connect(browserWsUrl)
try {
await browserConnection.send('Target.createTarget', { url: 'about:blank' })
} finally {
await browserConnection.close().catch(() => {})
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isRetryableCDPError(error) {
const message = formatError(error)
return (
message === 'CDP websocket closed' ||
message.startsWith('CDP command timed out:')
)
}
class CDPConnection {
static async connect(wsUrl) {
const WebSocketImpl = globalThis.WebSocket ?? (await import('ws')).default
const ws = new WebSocketImpl(wsUrl)
return await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
try {
ws.close()
} catch {
// ignore
}
reject(new Error('timed out opening CDP websocket'))
}, STARTUP_TIMEOUT_MS)
ws.addEventListener('open', () => {
clearTimeout(timeoutId)
resolve(new CDPConnection(ws))
})
ws.addEventListener('error', () => {
clearTimeout(timeoutId)
reject(new Error('CDP websocket error'))
})
})
}
constructor(ws) {
this.ws = ws
this.lastId = 0
this.pending = new Map()
this.listeners = new Set()
ws.addEventListener('message', (event) => {
const payload = JSON.parse(event.data.toString())
if (payload.id != null) {
const request = this.pending.get(payload.id)
if (!request) {
return
}
this.pending.delete(payload.id)
clearTimeout(request.timeoutId)
if (payload.error) {
request.reject(new Error(payload.error.message ?? 'CDP command failed'))
return
}
request.resolve(payload.result ?? {})
return
}
for (const listener of this.listeners) {
listener(payload)
}
})
ws.addEventListener('close', () => {
for (const request of this.pending.values()) {
clearTimeout(request.timeoutId)
request.reject(new Error('CDP websocket closed'))
}
this.pending.clear()
})
}
send(method, params = {}) {
const id = ++this.lastId
const message = { id, method, params }
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`CDP command timed out: ${method}`))
}, COMMAND_TIMEOUT_MS)
this.pending.set(id, { resolve, reject, timeoutId })
try {
this.ws.send(JSON.stringify(message))
} catch (error) {
clearTimeout(timeoutId)
this.pending.delete(id)
reject(error)
}
})
}
onEvent(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
waitForEvent(predicate, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
unsubscribe()
reject(new Error(`timed out waiting for ${label}`))
}, timeoutMs)
const unsubscribe = this.onEvent((event) => {
if (!predicate(event)) {
return
}
clearTimeout(timeoutId)
unsubscribe()
resolve(event)
})
})
}
async close() {
if (this.ws.readyState === 3) {
return
}
await Promise.race([
new Promise((resolve) => {
this.ws.addEventListener('close', () => resolve(), { once: true })
this.ws.close()
}),
delay(3000),
])
}
}
async function runSmoke(connection, options) {
const consoleErrors = []
const runtimeExceptions = []
const networkFailures = []
const consoleEntries = []
const javascriptDialogs = []
const popupWindows = []
const unsubscribe = connection.onEvent((event) => {
if (event.method === 'Runtime.consoleAPICalled') {
const entry = formatConsoleEntry(event.params)
consoleEntries.push(entry)
if (entry.type === 'error' && !isIgnorableConsoleError(entry.text)) {
consoleErrors.push(entry.text)
}
return
}
if (event.method === 'Runtime.exceptionThrown') {
runtimeExceptions.push(event.params.exceptionDetails?.text ?? 'unknown exception')
return
}
if (event.method === 'Page.javascriptDialogOpening') {
const dialog = {
type: event.params.type ?? 'unknown',
message: event.params.message ?? '',
defaultPrompt: event.params.defaultPrompt ?? '',
}
javascriptDialogs.push(dialog)
void connection.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {})
return
}
if (event.method === 'Page.windowOpen') {
popupWindows.push({
url: event.params.url ?? '',
windowName: event.params.windowName ?? '',
userGesture: event.params.userGesture ?? false,
})
return
}
if (event.method === 'Network.loadingFailed') {
const failure = {
errorText: event.params.errorText,
canceled: event.params.canceled,
type: event.params.type,
}
if (!isIgnorableNetworkFailure(failure)) {
networkFailures.push(failure)
}
}
})
try {
logDebug('enabling domains')
await connection.send('Page.enable')
await connection.send('Runtime.enable')
await connection.send('Log.enable')
await connection.send('Network.enable')
const loadTimings = []
logDebug('checking protected route redirect')
const protectedRedirects = {
dashboard: await assertProtectedRouteRedirect(
connection,
options.dashboardUrl,
'/dashboard',
options,
),
users: await assertProtectedRouteRedirect(connection, options.usersUrl, '/users', options),
}
logDebug('navigating to login')
const initialLoadMs = await navigateAndWait(connection, options.loginUrl, options)
loadTimings.push({ name: 'login-initial', ms: initialLoadMs })
logDebug('checking initial page state')
const initialState = await waitForCondition(
connection,
`
(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
}
const visibleInputs = Array.from(document.querySelectorAll('input'))
.filter(isVisible)
.map((input) => input.getAttribute('placeholder') ?? '')
const visibleLinks = Array.from(document.querySelectorAll('a'))
.filter(isVisible)
.map((link) => link.textContent?.trim() ?? '')
const visibleTabs = Array.from(document.querySelectorAll('[role="tab"], .ant-tabs-tab-btn'))
.filter(isVisible)
.map((tab) => tab.textContent?.trim() ?? '')
.filter(Boolean)
return {
title: document.title,
lang: document.documentElement.lang,
bodyText: document.body?.innerText ?? '',
path: location.pathname,
visibleInputs,
visibleLinks,
visibleTabs,
capabilities: null,
}
})()
`,
(state) =>
state.path === '/login' &&
state.title.includes(TEXT.appTitle) &&
state.lang === 'zh-CN' &&
state.bodyText.includes(TEXT.welcomeLogin) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)),
options.assertTimeoutMs,
'login page state',
)
const authCapabilities =
(await evaluate(
connection,
`
(async () => {
const response = await fetch('/api/v1/auth/capabilities')
if (!response.ok) {
return null
}
const payload = await response.json()
return payload?.data ?? null
})()
`,
)) ??
{
password: true,
email_code: initialState.visibleTabs.some((tab) => tab.includes(TEXT.emailCodeLogin)),
sms_code: initialState.visibleTabs.some((tab) => tab.includes(TEXT.smsCodeLogin)),
password_reset: initialState.visibleLinks.some((link) => link.includes(TEXT.forgotPassword)),
oauth_providers: [],
}
if (authCapabilities.password !== true) {
throw new Error(`unexpected auth capabilities: ${JSON.stringify(authCapabilities)}`)
}
if (
Boolean(authCapabilities.email_code) !==
initialState.visibleTabs.some((tab) => tab.includes(TEXT.emailCodeLogin))
) {
throw new Error(
`email capability mismatch: capabilities=${JSON.stringify(authCapabilities)} state=${JSON.stringify(initialState)}`,
)
}
if (
Boolean(authCapabilities.sms_code) !==
initialState.visibleTabs.some((tab) => tab.includes(TEXT.smsCodeLogin))
) {
throw new Error(
`sms capability mismatch: capabilities=${JSON.stringify(authCapabilities)} state=${JSON.stringify(initialState)}`,
)
}
if (
Boolean(authCapabilities.password_reset) !==
initialState.visibleLinks.some((link) => link.includes(TEXT.forgotPassword))
) {
throw new Error(
`password reset capability mismatch: capabilities=${JSON.stringify(authCapabilities)} state=${JSON.stringify(initialState)}`,
)
}
let emailState = null
if (authCapabilities.email_code) {
logDebug('switching to email tab')
await clickText(connection, '.ant-tabs-tab-btn, [role="tab"]', TEXT.emailCodeLogin)
emailState = await waitForCondition(
connection,
`
(() => Array.from(document.querySelectorAll('input'))
.filter((element) => {
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? '')
)()
`,
(placeholders) =>
placeholders.some((placeholder) => placeholder.includes(TEXT.emailPlaceholder)) &&
placeholders.some((placeholder) => placeholder.includes(TEXT.codePlaceholder)),
options.assertTimeoutMs,
'email login tab',
)
}
let smsState = null
if (authCapabilities.sms_code) {
logDebug('switching to sms tab')
await clickText(connection, '.ant-tabs-tab-btn, [role="tab"]', TEXT.smsCodeLogin)
smsState = await waitForCondition(
connection,
`
(() => Array.from(document.querySelectorAll('input'))
.filter((element) => {
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? '')
)()
`,
(placeholders) =>
placeholders.some((placeholder) => placeholder.includes(TEXT.phonePlaceholder)) &&
placeholders.some((placeholder) => placeholder.includes(TEXT.codePlaceholder)),
options.assertTimeoutMs,
'sms login tab',
)
}
let forgotState = null
if (authCapabilities.password_reset) {
logDebug('opening forgot password route')
await navigateAndWait(connection, options.loginUrl, options)
await clickText(connection, 'a', TEXT.forgotPassword)
forgotState = await waitForCondition(
connection,
`
(() => ({
path: location.pathname,
bodyText: document.body?.innerText ?? '',
title: document.title,
}))()
`,
(state) => state.path === '/forgot-password' && state.bodyText.includes(TEXT.forgotPassword),
options.assertTimeoutMs,
'forgot password route',
)
}
logDebug('running responsive checks')
const responsiveChecks = []
for (const viewport of [
{ name: 'desktop', width: 1920, height: 1080, mobile: false },
{ name: 'tablet', width: 768, height: 1024, mobile: false },
{ name: 'mobile', width: 375, height: 667, mobile: true },
]) {
logDebug(`viewport ${viewport.name}`)
await connection.send('Emulation.setDeviceMetricsOverride', {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: 1,
mobile: viewport.mobile,
})
const loadMs = await navigateAndWait(connection, options.loginUrl, options)
loadTimings.push({ name: `login-${viewport.name}`, ms: loadMs })
const state = await waitForCondition(
connection,
`
(() => ({
innerWidth: window.innerWidth,
bodyScrollWidth: document.body.scrollWidth,
path: location.pathname,
visibleInputs: Array.from(document.querySelectorAll('input'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? ''),
visibleLinks: Array.from(document.querySelectorAll('a'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((link) => link.textContent?.trim() ?? ''),
visibleTabs: Array.from(document.querySelectorAll('[role="tab"], .ant-tabs-tab-btn'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((tab) => tab.textContent?.trim() ?? ''),
}))()
`,
(state) =>
state.path === '/login' &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)) &&
Boolean(authCapabilities.email_code) ===
state.visibleTabs.some((tab) => tab.includes(TEXT.emailCodeLogin)) &&
Boolean(authCapabilities.sms_code) ===
state.visibleTabs.some((tab) => tab.includes(TEXT.smsCodeLogin)) &&
Boolean(authCapabilities.password_reset) ===
state.visibleLinks.some((link) => link.includes(TEXT.forgotPassword)),
options.assertTimeoutMs,
`${viewport.name} viewport`,
)
if (Math.abs(state.innerWidth - viewport.width) > 1) {
throw new Error(
`${viewport.name} viewport width mismatch: expected ${viewport.width}, got ${state.innerWidth}`,
)
}
if (state.bodyScrollWidth > state.innerWidth + 4) {
throw new Error(
`${viewport.name} viewport overflows horizontally: ${state.bodyScrollWidth} > ${state.innerWidth}`,
)
}
responsiveChecks.push({
name: viewport.name,
width: state.innerWidth,
bodyScrollWidth: state.bodyScrollWidth,
})
}
let authFlow = null
if (options.loginUsername && options.loginPassword) {
await setViewport(connection, { width: 1920, height: 1080, mobile: false })
logDebug('running authenticated flow')
authFlow = await runAuthenticatedFlow(connection, options)
}
if (consoleErrors.length > 0) {
throw new Error(`console errors detected: ${consoleErrors.join(' | ')}`)
}
if (runtimeExceptions.length > 0) {
throw new Error(`runtime exceptions detected: ${runtimeExceptions.join(' | ')}`)
}
if (networkFailures.length > 0) {
throw new Error(`network failures detected: ${JSON.stringify(networkFailures)}`)
}
if (javascriptDialogs.length > 0) {
throw new Error(`javascript dialogs detected: ${JSON.stringify(javascriptDialogs)}`)
}
if (popupWindows.length > 0) {
throw new Error(`popup windows detected: ${JSON.stringify(popupWindows)}`)
}
return {
browserVersion: options.browserVersion,
protectedRedirects,
initialState,
authCapabilities,
emailState,
smsState,
forgotState,
responsiveChecks,
loadTimings,
consoleEntries,
authFlow,
}
} finally {
unsubscribe()
}
}
async function assertProtectedRouteRedirect(connection, url, expectedFromPath, options) {
await navigateAndWait(connection, url, options)
return await waitForCondition(
connection,
`
(() => ({
path: location.pathname,
bodyText: document.body?.innerText ?? '',
title: document.title,
redirectFrom: history.state?.usr?.from?.pathname ?? null,
visibleInputs: Array.from(document.querySelectorAll('input'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? ''),
}))()
`,
(state) =>
state.path === '/login' &&
state.title.includes(TEXT.appTitle) &&
state.bodyText.includes(TEXT.loginAction) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)) &&
state.redirectFrom === expectedFromPath,
options.assertTimeoutMs,
`protected route redirect for ${expectedFromPath}`,
)
}
async function runAuthenticatedFlow(connection, options) {
const preLoginRedirect = await assertProtectedRouteRedirect(
connection,
options.usersUrl,
'/users',
options,
)
await setInputValue(
connection,
'input[autocomplete="username"], input[type="text"], input[type="email"]',
options.loginUsername,
)
await setInputValue(
connection,
'input[autocomplete="current-password"], input[type="password"]',
options.loginPassword,
)
await clickFirstVisible(connection, 'button[type="submit"]')
const loginState = await waitForUsersPage(connection, options, 'login success')
const userDetailState = await openUserDetailDrawer(connection, options.loginUsername, options)
logDebug('reloading users page after user detail drawer')
await navigateAndWait(connection, options.usersUrl, options)
await waitForUsersPage(connection, options, 'users page after user detail drawer')
logDebug('opening assign roles modal')
const assignRolesState = await openAssignRolesModal(connection, options.loginUsername, options)
logDebug('reloading users page after assign roles modal')
await navigateAndWait(connection, options.usersUrl, options)
await waitForUsersPage(connection, options, 'users page after assign roles modal')
logDebug('navigating to roles page from sidebar')
await clickSidebarMenuItem(connection, TEXT.roles)
logDebug('waiting for roles page')
const rolesState = await waitForRolesPage(connection, options)
logDebug('opening role permissions modal')
const rolePermissionsState = await openRolePermissionsModal(connection, options)
logDebug('navigating to dashboard after roles page checks')
await navigateAndWait(connection, options.dashboardUrl, options)
logDebug('waiting for dashboard page')
const dashboardState = await waitForDashboardPage(connection, options)
logDebug('opening user menu for logout')
await hoverFirstVisible(connection, '.ant-dropdown-trigger, [class*="userTrigger"]')
await waitForCondition(
connection,
`(() => document.body?.innerText ?? '')()`,
(bodyText) => typeof bodyText === 'string' && bodyText.includes(TEXT.logout),
Math.max(options.assertTimeoutMs, 10000),
'logout menu',
)
await clickText(
connection,
'[role="menuitem"], .ant-dropdown-menu-item, .ant-dropdown-menu-title-content',
TEXT.logout,
)
const logoutState = await waitForCondition(
connection,
`
(() => ({
path: location.pathname,
bodyText: document.body?.innerText ?? '',
refreshToken: localStorage.getItem('admin_refresh_token'),
visibleInputs: Array.from(document.querySelectorAll('input'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? ''),
}))()
`,
(state) =>
state.path === '/login' &&
state.bodyText.includes(TEXT.loginAction) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) &&
state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)) &&
state.refreshToken === null,
Math.max(options.assertTimeoutMs, 20000),
'logout success',
)
const postLogoutRedirects = {
dashboard: await assertProtectedRouteRedirect(
connection,
options.dashboardUrl,
'/dashboard',
options,
),
users: await assertProtectedRouteRedirect(connection, options.usersUrl, '/users', options),
}
return {
preLoginRedirect,
loginState,
userDetailState,
assignRolesState,
rolesState,
rolePermissionsState,
dashboardState,
logoutState,
postLogoutRedirects,
}
}
async function waitForDashboardPage(connection, options) {
return await waitForCondition(
connection,
`
(() => ({
path: location.pathname,
title: document.title,
bodyText: document.body?.innerText ?? '',
refreshToken: localStorage.getItem('admin_refresh_token'),
}))()
`,
(state) =>
state.path === '/dashboard' &&
state.title.includes(TEXT.appTitle) &&
state.bodyText.includes(TEXT.dashboard) &&
state.bodyText.includes(TEXT.totalUsers) &&
state.bodyText.includes(TEXT.todaySuccessLogins) &&
typeof state.refreshToken === 'string' &&
state.refreshToken.length > 0,
Math.max(options.assertTimeoutMs, 20000),
'dashboard access after login',
)
}
async function waitForUsersPage(connection, options, label) {
return await waitForCondition(
connection,
`
(() => ({
path: location.pathname,
title: document.title,
bodyText: document.body?.innerText ?? '',
refreshToken: localStorage.getItem('admin_refresh_token'),
rowTexts: Array.from(document.querySelectorAll('tbody tr'))
.map((row) => row.textContent?.trim() ?? '')
.filter(Boolean),
visibleInputs: Array.from(document.querySelectorAll('input'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? '')
.filter(Boolean),
}))()
`,
(state) =>
state.path === '/users' &&
state.title.includes(TEXT.appTitle) &&
state.bodyText.includes(TEXT.users) &&
state.bodyText.includes(options.loginUsername) &&
state.visibleInputs.some((text) => text.includes(TEXT.usersFilter)) &&
state.rowTexts.some((text) => text.includes(options.loginUsername)) &&
typeof state.refreshToken === 'string' &&
state.refreshToken.length > 0,
Math.max(options.assertTimeoutMs, 20000),
label,
)
}
async function waitForRolesPage(connection, options) {
return await waitForCondition(
connection,
`
(() => ({
path: location.pathname,
title: document.title,
bodyText: document.body?.innerText ?? '',
rowTexts: Array.from(document.querySelectorAll('tbody tr'))
.map((row) => row.textContent?.trim() ?? '')
.filter(Boolean),
visibleInputs: Array.from(document.querySelectorAll('input'))
.filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
})
.map((input) => input.getAttribute('placeholder') ?? '')
.filter(Boolean),
}))()
`,
(state) =>
state.path === '/roles' &&
state.title.includes(TEXT.appTitle) &&
state.bodyText.includes(TEXT.roles) &&
state.bodyText.includes(TEXT.createRole) &&
state.visibleInputs.some((text) => text.includes(TEXT.rolesFilter)) &&
state.rowTexts.some((text) => text.includes(TEXT.adminRoleName)),
Math.max(options.assertTimeoutMs, 20000),
'roles page',
)
}
async function openUserDetailDrawer(connection, username, options) {
logDebug(`opening user detail drawer for ${username}`)
await clickActionInTableRow(connection, username, '\u8be6\u60c5')
return await waitForCondition(
connection,
`
(() => {
const drawer = Array.from(document.querySelectorAll('.ant-drawer'))
.find((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
return element.getClientRects().length > 0
})
return {
path: location.pathname,
title: drawer?.querySelector('.ant-drawer-title')?.textContent?.trim() ?? '',
bodyText: drawer?.textContent?.trim() ?? '',
}
})()
`,
(state) =>
state.path === '/users' &&
state.title.includes(TEXT.userDetail) &&
state.bodyText.includes(TEXT.userId) &&
state.bodyText.includes(username),
Math.max(options.assertTimeoutMs, 20000),
'user detail drawer',
)
}
async function openAssignRolesModal(connection, username, options) {
logDebug(`opening assign roles modal for ${username}`)
await clickActionInTableRow(connection, username, '\u89d2\u8272')
return await waitForCondition(
connection,
`
(() => {
const modal = Array.from(document.querySelectorAll('.ant-modal-root'))
.find((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
return element.getClientRects().length > 0
})
return {
path: location.pathname,
title: modal?.querySelector('.ant-modal-title')?.textContent?.trim() ?? '',
bodyText: modal?.textContent?.trim() ?? '',
}
})()
`,
(state) =>
state.path === '/users' &&
state.title.includes(TEXT.assignRoles) &&
state.title.includes(username) &&
state.bodyText.includes(TEXT.assignableRoles) &&
state.bodyText.includes(TEXT.assignedRoles),
Math.max(options.assertTimeoutMs, 20000),
'assign roles modal',
)
}
async function openRolePermissionsModal(connection, options) {
logDebug(`opening role permissions modal for ${TEXT.adminRoleName}`)
await clickActionInTableRow(connection, TEXT.adminRoleName, '\u6743\u9650')
return await waitForCondition(
connection,
`
(() => {
const modal = Array.from(document.querySelectorAll('.ant-modal-root'))
.find((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
return element.getClientRects().length > 0
})
return {
path: location.pathname,
title: modal?.querySelector('.ant-modal-title')?.textContent?.trim() ?? '',
bodyText: modal?.textContent?.trim() ?? '',
treeNodeCount: modal?.querySelectorAll('.ant-tree-treenode').length ?? 0,
}
})()
`,
(state) =>
state.path === '/roles' &&
state.title.includes(TEXT.assignPermissions) &&
state.title.includes(TEXT.adminRoleName) &&
state.bodyText.includes(TEXT.permissionsHint) &&
(state.treeNodeCount > 0 || state.bodyText.includes('\u6682\u65e0\u6743\u9650\u6570\u636e')),
Math.max(options.assertTimeoutMs, 20000),
'role permissions modal',
)
}
async function navigateAndWait(connection, url, options) {
const startedAt = Date.now()
const previousHref =
(await evaluate(connection, `(() => location.href)()`).catch(() => null)) ?? null
const loadEvent = connection.waitForEvent(
(event) => event.method === 'Page.loadEventFired',
options.navigationTimeoutMs,
`load event for ${url}`,
)
const result = await connection.send('Page.navigate', { url })
if (result.errorText) {
throw new Error(`navigation failed for ${url}: ${result.errorText}`)
}
try {
await loadEvent
} catch (error) {
logDebug(`load event fallback for ${url}: ${formatError(error)}`)
await waitForCondition(
connection,
`
(() => ({
readyState: document.readyState,
href: location.href,
}))()
`,
(state) =>
state.readyState === 'complete' &&
(state.href === url || state.href !== previousHref),
Math.max(options.navigationTimeoutMs, 15000),
`document ready after navigation to ${url}`,
)
}
return Date.now() - startedAt
}
async function setViewport(connection, viewport) {
await connection.send('Emulation.setDeviceMetricsOverride', {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: 1,
mobile: viewport.mobile,
})
}
async function evaluate(connection, expression, options = {}) {
const result = await connection.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
userGesture: options.userGesture ?? false,
})
if (result.exceptionDetails) {
const description =
result.exceptionDetails.exception?.description ??
result.exceptionDetails.exception?.value ??
result.exceptionDetails.text ??
'Runtime.evaluate failed'
throw new Error(String(description))
}
return result.result?.value
}
async function waitForCondition(connection, expression, predicate, timeoutMs, label) {
const startedAt = Date.now()
let lastValue
while (Date.now() - startedAt < timeoutMs) {
lastValue = await evaluate(connection, expression)
if (predicate(lastValue)) {
return lastValue
}
await delay(150)
}
throw new Error(`timed out waiting for ${label}: ${JSON.stringify(lastValue)}`)
}
async function clickText(connection, selector, text) {
const clicked = await evaluate(
connection,
`
(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
}
const element = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
.find((node) => isVisible(node) && (node.textContent ?? '').includes(${JSON.stringify(text)}))
if (!element) {
return false
}
const target = element.closest('[role="menuitem"], button, a, li, .ant-dropdown-menu-item') || element
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
if (typeof target.click === 'function') {
target.click()
}
return true
})()
`,
{ userGesture: true },
)
if (!clicked) {
throw new Error(`failed to find clickable text "${text}" using selector "${selector}"`)
}
}
async function clickFirstVisible(connection, selector) {
const clicked = await evaluate(
connection,
`
(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
}
const element = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
.find((node) => isVisible(node))
if (!element) {
return false
}
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
if (typeof element.click === 'function') {
element.click()
}
return true
})()
`,
{ userGesture: true },
)
if (!clicked) {
throw new Error(`failed to find visible selector "${selector}"`)
}
}
async function clickActionInTableRow(connection, rowText, actionText) {
const clicked = await evaluate(
connection,
`
(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
}
const rows = Array.from(document.querySelectorAll('tbody tr'))
.filter((row) => isVisible(row) && (row.textContent ?? '').includes(${JSON.stringify(rowText)}))
const action = rows
.flatMap((row) => Array.from(row.querySelectorAll('button, a, [role="button"]')))
.find((node) => isVisible(node) && (node.textContent ?? '').includes(${JSON.stringify(actionText)}))
if (!(action instanceof HTMLElement)) {
return false
}
action.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
action.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
action.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
action.dispatchEvent(new MouseEvent('click', { bubbles: true }))
if (typeof action.click === 'function') {
action.click()
}
return true
})()
`,
{ userGesture: true },
)
if (!clicked) {
throw new Error(`failed to click action "${actionText}" in row "${rowText}"`)
}
}
async function clickSidebarMenuItem(connection, text) {
await clickText(
connection,
'.ant-layout-sider .ant-menu-item, .ant-layout-sider [role="menuitem"]',
text,
)
}
async function setInputValue(connection, selector, value) {
const updated = await evaluate(
connection,
`
(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
}
const input = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
.find((node) => node instanceof HTMLInputElement && isVisible(node))
if (!(input instanceof HTMLInputElement)) {
return false
}
const prototype = Object.getPrototypeOf(input)
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
if (!descriptor || typeof descriptor.set !== 'function') {
input.value = ${JSON.stringify(value)}
} else {
descriptor.set.call(input, ${JSON.stringify(value)})
}
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
return true
})()
`,
{ userGesture: true },
)
if (!updated) {
throw new Error(`failed to set input value for selector "${selector}"`)
}
}
async function hoverFirstVisible(connection, selector) {
const hovered = await evaluate(
connection,
`
(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0
}
const element = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
.find((node) => isVisible(node))
if (!(element instanceof HTMLElement)) {
return false
}
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
return true
})()
`,
{ userGesture: true },
)
if (!hovered) {
throw new Error(`failed to hover visible selector "${selector}"`)
}
}
function formatConsoleEntry(params) {
const text = (params.args ?? [])
.map((arg) => {
if (arg.value != null) {
return String(arg.value)
}
if (arg.unserializableValue != null) {
return arg.unserializableValue
}
if (arg.description != null) {
return arg.description
}
return arg.type ?? 'unknown'
})
.join(' ')
return {
type: params.type ?? 'log',
text,
}
}
function isIgnorableNetworkFailure(failure) {
return (
failure.canceled === true ||
failure.errorText === 'net::ERR_ABORTED' ||
failure.errorText === 'net::ERR_FAILED'
)
}
function isIgnorableConsoleError(text) {
return text.includes('Static function can not consume context like dynamic theme')
}
function printSummary(summary) {
console.log('CDP smoke completed successfully')
console.log(`browser: ${summary.browserVersion}`)
console.log(`title: ${summary.initialState.title}`)
console.log(
`capabilities: password=${summary.authCapabilities.password} email=${summary.authCapabilities.email_code} sms=${summary.authCapabilities.sms_code} passwordReset=${summary.authCapabilities.password_reset}`,
)
console.log(`tabs: ${summary.initialState.visibleTabs.join(', ')}`)
if (summary.forgotState) {
console.log(`forgot-password path: ${summary.forgotState.path}`)
} else {
console.log('forgot-password path: disabled')
}
console.log(
`protected dashboard redirect: ${summary.protectedRedirects.dashboard.path} (from=${summary.protectedRedirects.dashboard.redirectFrom})`,
)
console.log(
`protected users redirect: ${summary.protectedRedirects.users.path} (from=${summary.protectedRedirects.users.redirectFrom})`,
)
if (summary.authFlow) {
console.log(`pre-login users redirect from: ${summary.authFlow.preLoginRedirect.redirectFrom}`)
console.log(`login landing path: ${summary.authFlow.loginState.path}`)
console.log(`user detail title: ${summary.authFlow.userDetailState.title}`)
console.log(`assign roles title: ${summary.authFlow.assignRolesState.title}`)
console.log(`roles path: ${summary.authFlow.rolesState.path}`)
console.log(`permissions title: ${summary.authFlow.rolePermissionsState.title}`)
console.log(`dashboard path: ${summary.authFlow.dashboardState.path}`)
console.log(`logout path: ${summary.authFlow.logoutState.path}`)
console.log(
`post-logout dashboard redirect: ${summary.authFlow.postLogoutRedirects.dashboard.path} (from=${summary.authFlow.postLogoutRedirects.dashboard.redirectFrom})`,
)
console.log(
`post-logout users redirect: ${summary.authFlow.postLogoutRedirects.users.path} (from=${summary.authFlow.postLogoutRedirects.users.redirectFrom})`,
)
}
console.log('responsive:')
for (const viewport of summary.responsiveChecks) {
console.log(
` - ${viewport.name}: innerWidth=${viewport.width}, bodyScrollWidth=${viewport.bodyScrollWidth}`,
)
}
console.log('load timings:')
for (const timing of summary.loadTimings) {
console.log(` - ${timing.name}: ${timing.ms}ms`)
}
}
function logDebug(message) {
if (DEBUG) {
console.log(`[debug] ${message}`)
}
}
function formatError(error) {
if (error instanceof Error) {
return error.message
}
return String(error)
}
await main().catch((error) => {
console.error(`CDP smoke failed: ${formatError(error)}`)
process.exitCode = 1
})