1625 lines
52 KiB
JavaScript
1625 lines
52 KiB
JavaScript
|
|
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
|
||
|
|
})
|