Files
user-system/frontend/admin/scripts/run-playwright-cdp-e2e.mjs
long-agent 9b1cea246e feat: permissions CRUD browser integration + E2E enhancements
Backend:
- permission_handler: 完善权限 CRUD 接口(列表/创建/更新/删除)
- auth_handler: 修复认证处理逻辑
- router: 新增权限管理路由
- handler_test: 新增权限 handler 测试覆盖

Frontend:
- permissions.ts/test.ts: 权限服务层完整实现
- profile/settings/service_tests: 服务适配器修正
- client.ts: HTTP 客户端健壮性增强
- vite.config.js: 构建配置优化
- E2E 脚本: run-playwright-cdp-e2e 大幅增强(权限流程覆盖)

Docs:
- REAL_PROJECT_STATUS: 状态更新
- PRODUCTION_CHECKLIST/QUALITY_STANDARD/TECHNICAL_GUIDE/PROJECT_EXPERIENCE_SUMMARY: 团队规范完善
- plans/2026-04-23: 权限浏览器 CRUD 设计方案

验证: go build 0错误
2026-04-24 07:30:18 +08:00

2522 lines
93 KiB
JavaScript

import process from 'node:process'
import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { spawn } from 'node:child_process'
import { createHmac } from 'node:crypto'
import net from 'node:net'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { chromium, expect } from '@playwright/test'
const TEXT = {
accessControl: '\u8bbf\u95ee\u63a7\u5236',
active: '\u542f\u7528',
adminBootstrapTitle: '\u7cfb\u7edf\u5c1a\u672a\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
adminRoleName: '\u7ba1\u7406\u5458',
auditLogs: '\u5ba1\u8ba1\u65e5\u5fd7',
adminBootstrapAction: '\u521d\u59cb\u5316\u7ba1\u7406\u5458',
adminBootstrapPageTitle: '\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
appTitle: '\u7528\u6237\u7ba1\u7406\u7cfb\u7edf',
assignPermissions: '\u5206\u914d\u6743\u9650',
assignRoles: '\u5206\u914d\u89d2\u8272',
assignRolesAction: '\u89d2\u8272',
backToLogin: '\u8fd4\u56de\u767b\u5f55',
bootstrapAdminConfirmPasswordPlaceholder: '\u786e\u8ba4\u7ba1\u7406\u5458\u5bc6\u7801',
bootstrapAdminEmailPlaceholder: '\u7ba1\u7406\u5458\u90ae\u7bb1\uff08\u9009\u586b\uff09',
bootstrapAdminPasswordPlaceholder: '\u7ba1\u7406\u5458\u5bc6\u7801',
bootstrapAdminSecretPlaceholder: '\u5f15\u5bfc\u5bc6\u94a5',
bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf',
bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d',
changePassword: '\u4fee\u6539\u5bc6\u7801',
confirmDisableTOTP: '\u786e\u8ba4\u7981\u7528',
confirmEnableTOTP: '\u786e\u8ba4\u542f\u7528',
batchDelete: '\u6279\u91cf\u5220\u9664',
batchDisable: '\u6279\u91cf\u7981\u7528',
batchEnable: '\u6279\u91cf\u542f\u7528',
cancelSelection: '\u53d6\u6d88\u9009\u62e9',
confirmPasswordPlaceholder: '\u786e\u8ba4\u5bc6\u7801',
createAccount: '\u521b\u5efa\u8d26\u53f7',
createUser: '\u521b\u5efa\u7528\u6237',
createUserEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740',
createUserPasswordPlaceholder: '\u8bf7\u8f93\u5165\u521d\u59cb\u5bc6\u7801',
createUserUsernamePlaceholder: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
createRole: '\u521b\u5efa\u89d2\u8272',
createPermission: '\u521b\u5efa\u6743\u9650',
dashboard: '\u603b\u89c8',
delete: '\u5220\u9664',
deleteConfirm: '\u786e\u5b9a\u5220\u9664',
deviceManagement: '\u8bbe\u5907\u7ba1\u7406',
devices: '\u8bbe\u5907',
disableTOTP: '\u7981\u7528 TOTP',
disabled: '\u7981\u7528',
disabledStatus: '\u5df2\u7981\u7528',
downloadTemplate: '\u4e0b\u8f7d\u6a21\u677f',
edit: '\u7f16\u8f91',
editUser: '\u7f16\u8f91\u7528\u6237',
emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
enableTOTP: '\u542f\u7528 TOTP',
emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f',
export: '\u5bfc\u51fa',
exportUserData: '\u5bfc\u51fa\u7528\u6237\u6570\u636e',
exportUsers: '\u5bfc\u51fa\u7528\u6237',
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
importExport: '\u5bfc\u5165\u5bfc\u51fa',
importUsers: '\u5bfc\u5165\u7528\u6237',
integration: '\u96c6\u6210\u80fd\u529b',
loginAction: '\u767b\u5f55',
loginLogs: '\u767b\u5f55\u65e5\u5fd7',
loginNow: '\u7acb\u5373\u767b\u5f55',
logout: '\u9000\u51fa\u767b\u5f55',
logoutOthers: '\u9000\u51fa\u5176\u4ed6\u8bbe\u5907',
name: '\u540d\u79f0',
newPassword: '\u65b0\u5bc6\u7801',
newPasswordPlaceholder: '\u8bf7\u8f93\u5165\u65b0\u5bc6\u7801',
nickname: '\u6635\u79f0',
oldPassword: '\u5f53\u524d\u5bc6\u7801',
oldPasswordPlaceholder: '\u8bf7\u8f93\u5165\u5f53\u524d\u5bc6\u7801',
operationLogs: '\u64cd\u4f5c\u65e5\u5fd7',
passwordPlaceholder: '\u5bc6\u7801',
permissionCode: '\u6743\u9650\u4ee3\u7801',
permissionIcon: '\u56fe\u6807',
permissionName: '\u6743\u9650\u540d\u79f0',
permissionPath: '\u8def\u7531\u8def\u5f84',
permissions: '\u6743\u9650\u7ba1\u7406',
permissionsAction: '\u6743\u9650',
permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650',
profile: '\u4e2a\u4eba\u8d44\u6599',
profileConfirmPasswordPlaceholder: '\u8bf7\u518d\u6b21\u8f93\u5165\u65b0\u5bc6\u7801',
registerEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740\uff08\u9009\u586b\uff09',
registerSuccess: '\u6ce8\u518c\u6210\u529f',
roleFilter: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801',
roles: '\u89d2\u8272\u7ba1\u7406',
save: '\u4fdd\u5b58',
security: '\u5b89\u5168\u8bbe\u7f6e',
selectedUsers: '\u5df2\u9009\u62e9',
smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801',
status: '\u72b6\u6001',
settings: '\u7cfb\u7edf\u8bbe\u7f6e',
systemInfo: '\u7cfb\u7edf\u4fe1\u606f',
listView: '\u5217\u8868',
todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55',
totalUsers: '\u7528\u6237\u603b\u6570',
treeView: '\u6811\u5f62',
trust: '\u4fe1\u4efb',
untrust: '\u53d6\u6d88\u4fe1\u4efb',
userCreatedStatus: '\u5df2\u6fc0\u6d3b',
userDetail: '\u7528\u6237\u8be6\u60c5',
userDetailAction: '\u8be6\u60c5',
userId: '\u7528\u6237 ID',
usernamePlaceholder: '\u7528\u6237\u540d',
users: '\u7528\u6237\u7ba1\u7406',
usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7',
webhooks: 'Webhook 管理',
welcomeLogin: '\u6b22\u8fce\u767b\u5f55',
}
const BASE_URL = (process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000').replace(/\/$/, '')
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 960 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'mobile', width: 390, height: 844 },
]
const IGNORED_CONSOLE_ERRORS = [
'Static function can not consume context like dynamic theme',
]
const IGNORED_REQUEST_FAILURES = new Set([
'net::ERR_ABORTED',
'net::ERR_FAILED',
])
const DEBUG = process.env.E2E_DEBUG === '1'
const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 60000)
const CDP_CONNECT_TIMEOUT_MS = Number(process.env.E2E_CDP_CONNECT_TIMEOUT_MS ?? STARTUP_TIMEOUT_MS)
const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
let managedCdpUrl = null
function appUrl(pathname) {
return new URL(pathname, `${BASE_URL}/`).toString()
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function decodeBase32(value) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const normalized = String(value ?? '')
.toUpperCase()
.replace(/=+$/g, '')
.replace(/[^A-Z2-7]/g, '')
if (!normalized) {
throw new Error('TOTP secret is missing or invalid.')
}
let bits = 0
let bitCount = 0
const bytes = []
for (const character of normalized) {
const index = alphabet.indexOf(character)
if (index === -1) {
throw new Error(`Invalid base32 character: ${character}`)
}
bits = (bits << 5) | index
bitCount += 5
while (bitCount >= 8) {
bitCount -= 8
bytes.push((bits >>> bitCount) & 0xff)
}
}
return Buffer.from(bytes)
}
function generateTotpCode(secret, timestampMs = Date.now()) {
const key = decodeBase32(secret)
const counter = BigInt(Math.floor(timestampMs / 1000 / 30))
const payload = Buffer.alloc(8)
payload.writeBigUInt64BE(counter)
const digest = createHmac('sha1', key).update(payload).digest()
const offset = digest[digest.length - 1] & 0x0f
const binary = (
((digest[offset] & 0x7f) << 24)
| ((digest[offset + 1] & 0xff) << 16)
| ((digest[offset + 2] & 0xff) << 8)
| (digest[offset + 3] & 0xff)
)
return String(binary % 1_000_000).padStart(6, '0')
}
function requireEnv(name) {
const value = (process.env[name] ?? '').trim()
if (!value) {
throw new Error(`${name} is required.`)
}
return value
}
async function readCapturedMessages() {
if (!SMTP_CAPTURE_FILE) {
return []
}
try {
const content = await readFile(SMTP_CAPTURE_FILE, 'utf8')
return content
.split(/\r?\n/)
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line)]
} catch {
return []
}
})
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return []
}
throw error
}
}
function normalizeEmail(value) {
return String(value ?? '')
.trim()
.replace(/^<|>$/g, '')
.toLowerCase()
}
function capturedMessageMatchesRecipient(message, email) {
const target = normalizeEmail(email)
if (!target) {
return false
}
const recipients = Array.isArray(message?.rcptTo) ? message.rcptTo : []
return recipients.some((candidate) => normalizeEmail(candidate) === target)
}
function extractActivationLink(message) {
const body = String(message?.data ?? '')
const match = body.match(/https?:\/\/[^\s"<]+\/activate-account\?token=[^"\s<]+/)
return match?.[0] ?? null
}
function extractPasswordResetLink(message) {
const body = String(message?.data ?? '')
const match = body.match(/https?:\/\/[^\s"<]+\/reset-password\?token=[^"\s<]+/)
return match?.[0] ?? null
}
async function waitForActivationLink(email, timeoutMs = 20_000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const messages = await readCapturedMessages()
const matchedMessages = messages.filter((message) => capturedMessageMatchesRecipient(message, email))
for (let index = matchedMessages.length - 1; index >= 0; index -= 1) {
const activationLink = extractActivationLink(matchedMessages[index])
if (activationLink) {
return activationLink
}
}
await delay(250)
}
throw new Error(`Timed out waiting for activation email for ${email}.`)
}
async function waitForPasswordResetLink(email, timeoutMs = 20_000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const messages = await readCapturedMessages()
const matchedMessages = messages.filter((message) => capturedMessageMatchesRecipient(message, email))
for (let index = matchedMessages.length - 1; index >= 0; index -= 1) {
const resetLink = extractPasswordResetLink(matchedMessages[index])
if (resetLink) {
return resetLink
}
}
await delay(250)
}
throw new Error(`Timed out waiting for password reset email for ${email}.`)
}
function resolveCdpUrl() {
if (managedCdpUrl) {
return managedCdpUrl
}
const baseUrl = (process.env.E2E_PLAYWRIGHT_CDP_URL ?? process.env.E2E_CDP_BASE_URL ?? '').trim()
if (baseUrl) {
return baseUrl
}
const port = Number(process.env.E2E_CDP_PORT ?? 0)
if (port > 0) {
return `http://127.0.0.1:${port}`
}
throw new Error('E2E_PLAYWRIGHT_CDP_URL or E2E_CDP_PORT is required.')
}
function createSignals() {
return {
browserLifecycle: [],
rateLimitedResponses: [],
consoleErrors: [],
dialogs: [],
pageErrors: [],
pageLifecycle: [],
popups: [],
requestFailures: [],
unauthorizedResponses: [],
windowGuardEvents: [],
}
}
function logDebug(message) {
if (DEBUG) {
console.log(`[debug] ${message}`)
}
}
function formatError(error) {
if (!error) {
return 'unknown error'
}
if (error instanceof Error) {
return error.message || error.name
}
return String(error)
}
function isRetryableTargetError(error) {
const message = formatError(error)
return (
message.includes('Target page, context or browser has been closed') ||
message.includes('Target closed') ||
message.includes('Browser has been closed')
)
}
async function assertFileExists(filePath) {
await access(filePath, fsConstants.F_OK)
}
function isHeadlessShellBrowser(browserPath) {
return path.basename(browserPath).toLowerCase().includes('headless-shell')
}
async function resolveManagedBrowserPath() {
const envCandidates = [
process.env.E2E_BROWSER_PATH,
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
process.env.CHROME_HEADLESS_SHELL_PATH,
]
.map((value) => (value ?? '').trim())
.filter(Boolean)
for (const candidate of envCandidates) {
await assertFileExists(candidate)
return candidate
}
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() || !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('No compatible browser found for Playwright CDP E2E.')
}
async function createManagedBrowserProfileDir(browserPath, port) {
if (!isHeadlessShellBrowser(browserPath)) {
return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-'))
}
const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles')
await mkdir(profileRoot, { recursive: true })
return path.join(profileRoot, `pw-profile-playwright-cdp-${port}`)
}
function startManagedBrowser(browserPath, port, profileDir) {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--disable-gpu',
]
if (isHeadlessShellBrowser(browserPath)) {
args.push('--single-process')
} else {
args.push('--headless=new')
}
args.push('about:blank')
const browserProcess = spawn(browserPath, args, {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
})
let stderr = ''
browserProcess.stderr?.on('data', (chunk) => {
stderr += chunk.toString()
if (stderr.length > 4000) {
stderr = stderr.slice(-4000)
}
})
browserProcess.on('exit', (code, signal) => {
if (code !== 0 && signal == null) {
console.error(`managed browser exited unexpectedly with code ${code}`)
const details = stderr.trim()
if (details) {
console.error(details)
}
}
})
return browserProcess
}
async function killManagedBrowser(browserProcess) {
if (!browserProcess || browserProcess.exitCode != null || browserProcess.pid == null) {
return
}
await new Promise((resolve) => {
const killer = spawn('taskkill', ['/PID', String(browserProcess.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
})
killer.once('error', () => {
try {
browserProcess.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 = null
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, label = url) {
const response = await waitForHttp(url, timeoutMs, label)
return await response.json()
}
function isIgnoredConsoleError(text) {
if (text.includes('favicon') && text.includes('404')) {
return true
}
return IGNORED_CONSOLE_ERRORS.some((value) => text.includes(value))
}
function describePageState(page) {
try {
const url = page.url()
return page.isClosed() ? `closed url=${url}` : `open url=${url}`
} catch (error) {
return `unavailable (${formatError(error)})`
}
}
function formatSignals(signals) {
const lines = []
if (signals.browserLifecycle.length > 0) {
lines.push(`browser lifecycle:\n${signals.browserLifecycle.join('\n')}`)
}
if (signals.windowGuardEvents.length > 0) {
lines.push(`window-guard events:\n${signals.windowGuardEvents.join('\n')}`)
}
if (signals.dialogs.length > 0) {
lines.push(`native dialogs:\n${signals.dialogs.join('\n')}`)
}
if (signals.pageLifecycle.length > 0) {
lines.push(`page lifecycle:\n${signals.pageLifecycle.join('\n')}`)
}
if (signals.popups.length > 0) {
lines.push(`popup pages:\n${signals.popups.join('\n')}`)
}
if (signals.pageErrors.length > 0) {
lines.push(`page errors:\n${signals.pageErrors.join('\n\n')}`)
}
if (signals.consoleErrors.length > 0) {
lines.push(`console errors:\n${signals.consoleErrors.join('\n')}`)
}
if (signals.requestFailures.length > 0) {
lines.push(`request failures:\n${signals.requestFailures.join('\n')}`)
}
if (signals.rateLimitedResponses.length > 0) {
lines.push(`rate-limited responses:\n${signals.rateLimitedResponses.join('\n')}`)
}
if (signals.unauthorizedResponses.length > 0) {
lines.push(`unauthorized responses:\n${signals.unauthorizedResponses.join('\n')}`)
}
return lines.join('\n\n')
}
function assertCleanSignals(signals) {
const output = formatSignals(signals)
if (output) {
throw new Error(output)
}
}
function attachSignalCollectors(page, signals) {
const browser = page.context().browser()
const onConsole = (message) => {
if (message.type() !== 'error') {
return
}
const text = message.text()
if (text.startsWith('[window-guard]')) {
signals.windowGuardEvents.push(text)
return
}
if (!isIgnoredConsoleError(text)) {
signals.consoleErrors.push(text)
}
}
const onDialog = (dialog) => {
signals.dialogs.push(`${dialog.type()}: ${dialog.message()}`)
void dialog.dismiss().catch(() => {})
}
const onPageError = (error) => {
signals.pageErrors.push(error.stack ?? error.message)
}
const onPopup = (popup) => {
signals.popups.push(popup.url() || 'about:blank')
void popup.close().catch(() => {})
}
const onPageClose = () => {
signals.pageLifecycle.push(`close :: ${describePageState(page)}`)
}
const onPageCrash = () => {
signals.pageLifecycle.push(`crash :: ${describePageState(page)}`)
}
const onRequestFailed = (request) => {
const failureText = request.failure()?.errorText ?? 'unknown failure'
if (!IGNORED_REQUEST_FAILURES.has(failureText)) {
signals.requestFailures.push(`${request.method()} ${request.url()} :: ${failureText}`)
}
}
const onResponse = (response) => {
if (response.status() === 429) {
signals.rateLimitedResponses.push(`${response.request().method()} ${response.url()}`)
}
if (response.status() === 401) {
const authorization = response.request().headers().authorization
const authState = authorization
? `auth=present(${authorization.slice(0, 24)})`
: 'auth=missing'
const summary = `${response.request().method()} ${response.url()} :: ${authState}`
signals.unauthorizedResponses.push(summary)
void response.text().then((body) => {
const compactBody = body.replace(/\s+/g, ' ').trim()
if (compactBody) {
signals.unauthorizedResponses.push(`${summary} :: ${compactBody}`)
}
}).catch(() => {})
}
}
const onBrowserDisconnected = () => {
signals.browserLifecycle.push(`disconnected :: ${describePageState(page)}`)
}
page.on('console', onConsole)
page.on('dialog', onDialog)
page.on('pageerror', onPageError)
page.on('close', onPageClose)
page.on('crash', onPageCrash)
page.on('popup', onPopup)
page.on('requestfailed', onRequestFailed)
page.on('response', onResponse)
browser?.on('disconnected', onBrowserDisconnected)
return () => {
page.off('console', onConsole)
page.off('dialog', onDialog)
page.off('pageerror', onPageError)
page.off('close', onPageClose)
page.off('crash', onPageCrash)
page.off('popup', onPopup)
page.off('requestfailed', onRequestFailed)
page.off('response', onResponse)
browser?.off('disconnected', onBrowserDisconnected)
}
}
async function resetBrowserState(context, page) {
logDebug('resetting browser state')
await context.clearCookies()
await page.setViewportSize({ width: VIEWPORTS[0].width, height: VIEWPORTS[0].height })
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.clear()
sessionStorage.clear()
})
await page.goto('about:blank')
}
async function openDevToolsPageTarget() {
const endpoints = [
`${resolveCdpUrl()}/json/new?about:blank`,
`${resolveCdpUrl()}/json/new?url=about:blank`,
]
for (const endpoint of endpoints) {
for (const method of ['PUT', 'GET']) {
try {
await fetch(endpoint, { method })
return
} catch {
// try next variant
}
}
}
}
async function connectBrowserWithRetry() {
let lastError = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await chromium.connectOverCDP(resolveCdpUrl(), {
timeout: CDP_CONNECT_TIMEOUT_MS,
})
} catch (error) {
lastError = error
if (attempt >= 3) {
break
}
await delay(500)
}
}
throw lastError ?? new Error('Failed to connect to the Chromium CDP endpoint.')
}
async function reconnectBrowserConnection(browser) {
await browser?.close().catch(() => {})
return await connectBrowserWithRetry()
}
function findOpenPage(browser, preferredContext) {
const contexts = []
if (preferredContext) {
contexts.push(preferredContext)
}
for (const candidateContext of browser.contexts()) {
if (candidateContext !== preferredContext) {
contexts.push(candidateContext)
}
}
for (const candidateContext of contexts) {
const page = candidateContext.pages().find((candidate) => !candidate.isClosed())
if (page) {
return { context: candidateContext, page }
}
}
return null
}
async function recoverPersistentPage(state) {
const preferredContext = state.browser?.contexts?.()[0] ?? state.context ?? null
let result = await ensurePersistentPage(state.browser, preferredContext)
if (result) {
state.context = result.context
return result
}
state.browser = await reconnectBrowserConnection(state.browser)
state.context = state.browser.contexts()[0] ?? state.context ?? null
result = await ensurePersistentPage(state.browser, state.context)
if (result) {
state.context = result.context
return result
}
return null
}
async function ensurePersistentPage(browser, context) {
let result = findOpenPage(browser, context)
if (result) {
return result
}
try {
const session = await browser.newBrowserCDPSession()
try {
await session.send('Target.createTarget', { url: 'about:blank' })
} finally {
await session.detach().catch(() => {})
}
} catch {
// fall through to DevTools HTTP endpoint fallback
}
await openDevToolsPageTarget()
for (let attempt = 0; attempt < 50; attempt += 1) {
result = findOpenPage(browser, context)
if (result) {
return result
}
await delay(100)
}
return null
}
async function getProtectedRouteRedirect(page) {
return await page.evaluate(() => {
return {
path: window.location.pathname,
redirectFrom: history.state?.usr?.from?.pathname ?? null,
title: document.title,
}
})
}
async function clickSidebarMenu(page, label) {
await expect
.poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
.toBeGreaterThan(0)
const menuItems = page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
.filter({ hasText: label })
const count = await menuItems.count()
for (let index = 0; index < count; index += 1) {
const menuItem = menuItems.nth(index)
if (await menuItem.isVisible()) {
await forceClick(menuItem)
return
}
}
throw new Error(`No visible menu item found for ${label}.`)
}
async function openMobileNavigationIfNeeded(page) {
const isMobileViewport = await page.evaluate(() => window.innerWidth < 768)
if (!isMobileViewport) {
return false
}
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
return false
}
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
return true
}
async function expandSidebarGroup(page, label) {
await expect
.poll(async () => {
return await page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.count()
})
.toBeGreaterThan(0)
const findVisibleGroup = async () => {
const groups = page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.filter({ hasText: label })
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
if (await group.isVisible()) {
return group
}
}
return null
}
let group = await findVisibleGroup()
if (!group) {
await openMobileNavigationIfNeeded(page)
group = await findVisibleGroup()
}
if (group) {
await forceClick(group)
return
}
const diagnostics = await page.evaluate(() => {
const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
.map((element) => (element.textContent ?? '').trim())
.filter(Boolean)
return {
currentUrl: window.location.href,
innerWidth: window.innerWidth,
submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
}
})
throw new Error(`No visible menu group found for ${label}. diagnostics=${JSON.stringify(diagnostics)}`)
}
async function forceFillInput(locator, value) {
await expect(locator).toBeVisible()
try {
await locator.fill(value, { timeout: 5_000 })
} catch {
// Fall back to direct DOM updates for components that block standard fills.
}
const currentValue = await locator.inputValue().catch(() => null)
if (currentValue === value) {
return
}
await locator.evaluate((element, nextValue) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error('Target element is not an input.')
}
element.focus()
const prototype = Object.getPrototypeOf(element)
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
if (descriptor?.set) {
descriptor.set.call(element, nextValue)
} else {
element.value = nextValue
}
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
}, value)
await expect(locator).toHaveValue(value)
}
async function forceClick(locator) {
await expect(locator).toBeVisible()
try {
await locator.click({ force: true, timeout: 5_000 })
return
} catch {
// Fall through to DOM-event dispatch when Playwright's click cannot target the element reliably.
}
await locator.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error('Target element is not clickable.')
}
element.scrollIntoView({ block: 'center', inline: 'center' })
const target =
element.closest(
'button, a, [role="button"], [role="menuitem"], .ant-btn, .ant-menu-item, .ant-modal-close, .ant-drawer-close',
) ?? 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 }))
})
}
async function readRefreshToken(page) {
return await readCookie(page, REFRESH_TOKEN_COOKIE_NAME)
}
async function readSessionPresenceCookie(page) {
return await readCookie(page, SESSION_PRESENCE_COOKIE_NAME)
}
async function readCookie(page, cookieName) {
const cookies = await page.context().cookies([BASE_URL])
const matched = cookies.find((cookie) => cookie.name === cookieName)
return matched?.value ?? null
}
async function installFetchDiagnostics(page) {
await page.evaluate(() => {
const globalWindow = window
if (!Array.isArray(globalWindow.__umsE2eFetchLog)) {
globalWindow.__umsE2eFetchLog = []
} else {
globalWindow.__umsE2eFetchLog.length = 0
}
if (globalWindow.__umsE2eFetchWrapped) {
return
}
const originalFetch = window.fetch.bind(window)
globalWindow.__umsE2eFetchWrapped = true
window.fetch = async (...args) => {
const [resource, init] = args
const isRequestObject = typeof Request !== 'undefined' && resource instanceof Request
const entry = {
url: isRequestObject ? resource.url : String(resource),
method: init?.method ?? (isRequestObject ? resource.method : 'GET'),
startedAt: Date.now(),
}
globalWindow.__umsE2eFetchLog.push(entry)
try {
const response = await originalFetch(...args)
entry.status = response.status
entry.ok = response.ok
entry.finishedAt = Date.now()
return response
} catch (error) {
entry.error = error instanceof Error ? error.message : String(error)
entry.finishedAt = Date.now()
throw error
}
}
})
}
async function readFetchDiagnostics(page) {
return await page.evaluate(() => {
const globalWindow = window
return Array.isArray(globalWindow.__umsE2eFetchLog) ? globalWindow.__umsE2eFetchLog : []
})
}
async function getFetchDiagnosticsCount(page) {
const fetchLog = await readFetchDiagnostics(page)
return fetchLog.length
}
function fetchLogPathMatches(entry, pattern) {
const pathname = new URL(entry.url).pathname
return pattern.test(pathname)
}
async function waitForFetchLogEntry(page, predicate, options = {}) {
const {
afterCount = 0,
timeout = 30 * 1000,
label = 'fetch request',
} = options
const deadline = Date.now() + timeout
let fetchLog = []
while (Date.now() < deadline) {
fetchLog = await readFetchDiagnostics(page)
const entry = fetchLog
.slice(afterCount)
.find((candidate) => candidate.finishedAt !== undefined && predicate(candidate))
if (entry) {
return entry
}
await delay(100)
}
throw new Error(`${label} did not complete within ${timeout}ms. fetchLog=${JSON.stringify(fetchLog.slice(afterCount))}`)
}
async function performActionAndWaitForFetchLogEntry(page, predicate, action, options = {}) {
const pendingFetch = waitForFetchLogEntry(page, predicate, options)
try {
await action()
} catch (error) {
void pendingFetch.catch(() => {})
throw error
}
return await pendingFetch
}
function assertFetchLogSuccess(entry, label) {
if (entry.ok) {
return
}
throw new Error(`${label} request failed: ${entry.status ?? 'unknown'} ${entry.url}`)
}
async function assertApiSuccessResponse(response, label) {
const responseBody = await response.text().catch(() => '')
if (!response.ok()) {
throw new Error(`${label} request failed: ${response.status()} ${responseBody}`)
}
let payload
try {
payload = JSON.parse(responseBody)
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`${label} response is not valid JSON: ${responseBody}`)
}
throw error
}
if (payload?.code !== 0) {
throw new Error(`${label} business response failed: ${responseBody}`)
}
return payload
}
async function assertHttpOkResponse(response, label) {
if (response.ok()) {
return
}
const responseBody = await response.text().catch(() => '')
throw new Error(`${label} request failed: ${response.status()} ${responseBody}`)
}
function waitForResponseSafe(page, predicate, options) {
return page.waitForResponse(predicate, options).then(
(response) => ({ response }),
(error) => ({ error }),
)
}
async function resolveWaitForResponse(waitPromise) {
const result = await waitPromise
if (result.error) {
throw result.error
}
return result.response
}
async function loginWithPassword(page, username, password, expectedUrlPattern) {
const usernameInput = page
.locator(`input[autocomplete="username"], input[placeholder="${TEXT.usernamePlaceholder}"]`)
.first()
const loginForm = usernameInput.locator('xpath=ancestor::form[1]')
const passwordInput = loginForm.locator('input[type="password"]').first()
const submitButton = loginForm.locator('button[type="submit"]').first()
await forceFillInput(usernameInput, username)
await forceFillInput(passwordInput, password)
await expect(usernameInput).toHaveValue(username)
await expect(passwordInput).toHaveValue(password)
const loginResponsePromise = page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
}, { timeout: 5_000 }).catch(() => null)
try {
await submitButton.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(submitButton)
}
const loginResponse = await loginResponsePromise
let loginPayload = null
if (loginResponse) {
loginPayload = await assertApiSuccessResponse(loginResponse, 'password login')
}
if (expectedUrlPattern) {
try {
await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 })
} catch (error) {
const pageText = await page.locator('body').innerText().catch(() => '')
console.error('PASSWORD LOGIN DIAGNOSTICS', JSON.stringify({
currentUrl: page.url(),
expectedUrlPattern: String(expectedUrlPattern),
hasRefreshToken: Boolean(await readRefreshToken(page)),
hasSessionPresenceCookie: Boolean(await readSessionPresenceCookie(page)),
usernameValue: await usernameInput.inputValue().catch(() => null),
passwordValueLength: await passwordInput.inputValue().then((value) => value.length).catch(() => null),
submitButtonDisabled: await submitButton.isDisabled().catch(() => null),
loginPayload,
pageText: pageText.slice(0, 2000),
}))
throw error
}
}
}
async function loginFromLoginPage(page) {
const username = requireEnv('E2E_LOGIN_USERNAME')
const password = requireEnv('E2E_LOGIN_PASSWORD')
await page.goto(appUrl('/login'))
await expect(page).toHaveURL(/\/login$/)
await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible()
await loginWithPassword(page, username, password, /\/dashboard$/)
await expect(page.locator('.ant-layout-header')).toBeVisible({ timeout: 20 * 1000 })
return { username, password }
}
async function createUserFromUsersPage(page, username, password = 'Batch123!@#') {
const email = `${username}@example.com`
const createUserModal = page.locator('.ant-modal').last()
logDebug(`createUserFromUsersPage: open modal for ${username}`)
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
logDebug(`createUserFromUsersPage: modal visible for ${username}`)
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
logDebug(`createUserFromUsersPage: fill username for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
username,
)
logDebug(`createUserFromUsersPage: fill password for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserPasswordPlaceholder}"]`).first(),
password,
)
logDebug(`createUserFromUsersPage: fill email for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
email,
)
logDebug(`createUserFromUsersPage: submit modal for ${username}`)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, `create user ${username}`)
logDebug(`createUserFromUsersPage: response ok for ${username}`)
await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 })
logDebug(`createUserFromUsersPage: row visible for ${username}`)
return { email, password, username }
}
async function logoutFromCurrentSession(page) {
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function resetSessionToLogin(page) {
const context = page.context()
await context.clearCookies()
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.clear()
sessionStorage.clear()
})
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
await expect(page).toHaveURL(/\/login$/)
}
function extractTotpSecret(modalText) {
const match = String(modalText ?? '').match(/\u5bc6\u94a5\uff1a([A-Z2-7]+)/)
if (!match) {
throw new Error(`Failed to extract TOTP secret from modal text: ${modalText}`)
}
return match[1]
}
async function getRecoveryCodesFromTotpModal(modal) {
const codes = (await modal.locator('.ant-tag').allInnerTexts())
.map((value) => value.trim())
.filter(Boolean)
if (codes.length === 0) {
throw new Error('No TOTP recovery codes were rendered in the setup modal.')
}
return codes
}
function permissionPathMatches(response, pattern) {
const pathname = new URL(response.url()).pathname
return pattern.test(pathname)
}
async function fillPermissionModal(modal, values) {
if (values.name) {
const nameInput = modal.locator('input[id="name"], input[placeholder="\u5982\uff1a\u7528\u6237\u7ba1\u7406"]').first()
await forceFillInput(nameInput, values.name)
await expect(nameInput).toHaveValue(values.name)
}
if (values.code) {
const codeInput = modal.locator('input[id="code"], input[placeholder="\u5982\uff1auser:manage"]').first()
await forceFillInput(codeInput, values.code)
await expect(codeInput).toHaveValue(values.code)
}
if (values.path) {
const pathInput = modal.locator('input[id="path"], input[placeholder="\u5982\uff1a/admin/users"]').first()
await forceFillInput(pathInput, values.path)
await expect(pathInput).toHaveValue(values.path)
}
if (values.icon) {
const iconInput = modal.locator('input[id="icon"], input[placeholder="\u5982\uff1aUserOutlined"]').first()
await forceFillInput(iconInput, values.icon)
await expect(iconInput).toHaveValue(values.icon)
}
}
async function confirmVisiblePopconfirm(page) {
const popconfirm = page.locator('.ant-popconfirm').last()
await expect(popconfirm).toBeVisible({ timeout: 10 * 1000 })
await forceClick(popconfirm.locator('.ant-btn-primary').last())
}
async function submitPermissionModalAndWaitForFetch(page, modal, waitForFetch, label) {
const submitButton = modal.locator('.ant-btn-primary').last()
await expect(submitButton).toBeEnabled()
await forceClick(submitButton)
const validationErrors = modal.locator('.ant-form-item-explain-error')
const validationSignal = await validationErrors.first().waitFor({ state: 'visible', timeout: 2_000 }).then(
async () => ({ errors: await validationErrors.allInnerTexts() }),
() => null,
)
if (validationSignal) {
throw new Error(`${label} validation failed: ${validationSignal.errors.join(' | ')}`)
}
try {
return await waitForFetch()
} catch (error) {
const modalText = await modal.innerText().catch(() => '')
const inputs = await modal.locator('input').evaluateAll((nodes) => {
return nodes.map((node) => ({
id: node.getAttribute('id'),
name: node.getAttribute('name'),
placeholder: node.getAttribute('placeholder'),
type: node.getAttribute('type'),
value: node.value,
}))
}).catch(() => [])
const submitButtonState = await submitButton.evaluate((node) => ({
className: node.className,
disabled: node instanceof HTMLButtonElement ? node.disabled : null,
text: node.textContent?.trim() ?? '',
})).catch(() => null)
const fetchLog = await readFetchDiagnostics(page).catch(() => [])
throw new Error(
`${label} request did not complete: ${formatError(error)} diagnostics=${JSON.stringify({
inputs,
modalText: modalText.slice(0, 1000),
submitButtonState,
fetchLog,
})}`,
)
}
}
async function waitForModalToStopBlocking(modal, label) {
const deadline = Date.now() + 20 * 1000
let lastState = null
while (Date.now() < deadline) {
lastState = await modal.evaluate((node) => {
const style = window.getComputedStyle(node)
return {
className: node.className,
display: style.display,
pointerEvents: style.pointerEvents,
visibility: style.visibility,
}
}).catch(() => ({
detached: true,
}))
if (
lastState.detached
|| lastState.display === 'none'
|| lastState.visibility === 'hidden'
|| lastState.pointerEvents === 'none'
|| String(lastState.className).includes('ant-zoom-leave')
) {
return
}
await delay(100)
}
throw new Error(`${label} modal did not stop blocking within 20000ms. state=${JSON.stringify(lastState)}`)
}
async function selectUserRow(page, username) {
const row = page.locator('tbody tr').filter({ hasText: username }).first()
await expect(row).toBeVisible({ timeout: 20 * 1000 })
const checkbox = row.locator('.ant-checkbox').first()
await forceClick(checkbox)
await expect(checkbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 })
}
async function verifyAdminBootstrapWorkflow(page) {
const username = requireEnv('E2E_LOGIN_USERNAME')
const password = requireEnv('E2E_LOGIN_PASSWORD')
const email = (process.env.E2E_LOGIN_EMAIL ?? `${username}@example.com`).trim()
const bootstrapSecret = requireEnv('E2E_BOOTSTRAP_SECRET')
const apiBaseUrl = requireEnv('E2E_API_BASE_URL')
const capabilitiesResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
})
await page.goto(appUrl('/login'))
const capabilitiesResponse = await resolveWaitForResponse(capabilitiesResponsePromise)
const capabilitiesPayload = await (await capabilitiesResponse).json()
expect(Boolean(capabilitiesPayload?.data?.admin_bootstrap_required)).toBe(true)
await expect(page.getByText(TEXT.adminBootstrapTitle)).toBeVisible()
await forceClick(page.getByRole('button', { name: TEXT.adminBootstrapAction }))
await expect(page).toHaveURL(/\/bootstrap-admin$/)
await expect(page.getByRole('heading', { name: TEXT.adminBootstrapPageTitle })).toBeVisible()
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminUsernamePlaceholder}"]`).first(), username)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminEmailPlaceholder}"]`).first(), email)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminSecretPlaceholder}"]`).first(), bootstrapSecret)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminPasswordPlaceholder}"]`).first(), password)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminConfirmPasswordPlaceholder}"]`).first(), password)
const bootstrapResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/bootstrap-admin') && response.request().method() === 'POST'
})
await forceClick(page.getByRole('button', { name: TEXT.bootstrapAdminSubmit }))
const bootstrapResponse = await resolveWaitForResponse(bootstrapResponsePromise)
await assertApiSuccessResponse(bootstrapResponse, 'bootstrap admin')
const bootstrapPayload = await bootstrapResponse.json()
expect(Boolean(bootstrapPayload?.data?.access_token)).toBe(true)
expect(Boolean(bootstrapPayload?.data?.user?.id)).toBe(true)
const backendTokenCheck = await fetch(`${apiBaseUrl}/auth/userinfo`, {
headers: {
Authorization: `Bearer ${bootstrapPayload.data.access_token}`,
},
})
const backendTokenCheckBody = await backendTokenCheck.text()
expect(backendTokenCheck.status, backendTokenCheckBody).toBe(200)
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30 * 1000 })
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
await expect(page.getByText(TEXT.adminBootstrapTitle)).toHaveCount(0)
}
async function verifyPublicRegistration(page) {
const username = `e2e_register_${Date.now()}`
const password = 'Register123!@#'
await page.goto(appUrl('/login'))
await expect(page.getByRole('link', { name: TEXT.createAccount })).toBeVisible()
await forceClick(page.getByRole('link', { name: TEXT.createAccount }))
await expect(page).toHaveURL(/\/register$/)
await expect(page.getByRole('heading', { name: TEXT.createAccount })).toBeVisible()
await forceFillInput(page.getByPlaceholder(TEXT.usernamePlaceholder), username)
await forceFillInput(page.locator(`input[placeholder="${TEXT.passwordPlaceholder}"]`).first(), password)
await forceFillInput(
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
password,
)
const registerResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
})
await forceClick(page.getByRole('button', { name: TEXT.createAccount }))
const registerResponse = await resolveWaitForResponse(registerResponsePromise)
await assertApiSuccessResponse(registerResponse, 'register')
await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.backToLogin }))
await expect(page).toHaveURL(/\/login$/)
await loginWithPassword(page, username, password, /\/profile$/)
await expect(page.locator('body')).toContainText(TEXT.profile)
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyEmailActivationWorkflow(page) {
const username = `e2e_activate_${Date.now()}`
const email = `${username}@example.com`
const password = 'Register123!@#'
await page.goto(appUrl('/register'))
await expect(page).toHaveURL(/\/register$/)
await expect(page.getByRole('heading', { name: TEXT.createAccount })).toBeVisible()
await forceFillInput(page.getByPlaceholder(TEXT.usernamePlaceholder), username)
await forceFillInput(page.getByPlaceholder(TEXT.registerEmailPlaceholder), email)
await forceFillInput(page.locator(`input[placeholder="${TEXT.passwordPlaceholder}"]`).first(), password)
await forceFillInput(
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
password,
)
const registerResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
})
await forceClick(page.getByRole('button', { name: TEXT.createAccount }))
const registerResponse = await resolveWaitForResponse(registerResponsePromise)
await assertApiSuccessResponse(registerResponse, 'register email activation')
await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 })
const activationLink = await waitForActivationLink(email)
const activationResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/activate-email') && response.request().method() === 'POST'
})
await page.goto(activationLink)
const activationResponse = await resolveWaitForResponse(activationResponsePromise)
await assertApiSuccessResponse(activationResponse, 'activate email')
await expect(page.locator('body')).toContainText(TEXT.emailActivationSuccess, { timeout: 20 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.loginNow }))
await expect(page).toHaveURL(/\/login$/)
await loginWithPassword(page, username, password, /\/profile$/)
await expect(page.locator('body')).toContainText(TEXT.profile)
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyPasswordResetWorkflow(page) {
logDebug('verifyPasswordResetWorkflow: create user through admin flow')
const username = `e2e_reset_${Date.now()}`
const oldPassword = 'ResetOld123!@#'
const newPassword = 'ResetNew456!@#'
await loginFromLoginPage(page)
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const { email } = await createUserFromUsersPage(page, username, oldPassword)
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
await forceClick(page.getByRole('link', { name: TEXT.forgotPassword }))
await expect(page).toHaveURL(/\/forgot-password$/)
const forgotPasswordForm = page.locator('form').first()
const forgotPasswordEmailInput = forgotPasswordForm.locator('input[autocomplete="email"]').first()
await forceFillInput(forgotPasswordEmailInput, email)
const forgotPasswordResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/forgot-password') && response.request().method() === 'POST'
})
await forceClick(forgotPasswordForm.locator('button[type="submit"]').first())
const forgotPasswordResponse = await resolveWaitForResponse(forgotPasswordResponsePromise)
await assertApiSuccessResponse(forgotPasswordResponse, 'request password reset')
await expect(page.locator('body')).toContainText(email, { timeout: 20 * 1000 })
const resetLink = await waitForPasswordResetLink(email)
const validateResetTokenResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/password/validate') && response.request().method() === 'POST'
})
await page.goto(resetLink)
const validateResetTokenResponse = await resolveWaitForResponse(validateResetTokenResponsePromise)
const validateResetTokenPayload = await assertApiSuccessResponse(
validateResetTokenResponse,
'validate password reset token',
)
expect(validateResetTokenPayload?.data?.valid).toBe(true)
const resetPasswordInputs = page.locator('input[type="password"]')
await expect(resetPasswordInputs).toHaveCount(2, { timeout: 20 * 1000 })
await forceFillInput(resetPasswordInputs.nth(0), newPassword)
await forceFillInput(resetPasswordInputs.nth(1), newPassword)
const resetPasswordResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/reset-password') && response.request().method() === 'POST'
})
await forceClick(page.locator('form').first().locator('button[type="submit"]').first())
const resetPasswordResponse = await resolveWaitForResponse(resetPasswordResponsePromise)
await assertApiSuccessResponse(resetPasswordResponse, 'reset password')
await page.goto(appUrl('/login'))
await loginWithPassword(page, username, newPassword, /\/(dashboard|profile)$/)
}
async function runScenario(state, name, fn) {
console.log(`START ${name}`)
let lastError = null
for (let attempt = 1; attempt <= 2; attempt += 1) {
const resolvedPage = await recoverPersistentPage(state)
if (!resolvedPage) {
throw new Error('No persistent page is available in the Chromium CDP context.')
}
const activeContext = resolvedPage.context
const page = resolvedPage.page
state.context = activeContext
for (const extraPage of activeContext.pages()) {
if (extraPage === page) {
continue
}
await extraPage.close({ runBeforeUnload: false }).catch(() => {})
}
const signals = createSignals()
const detachSignals = attachSignalCollectors(page, signals)
const startedAt = Date.now()
try {
console.log(`STEP ${name} reset-state`)
await resetBrowserState(activeContext, page)
console.log(`STEP ${name} execute`)
await fn(page)
assertCleanSignals(signals)
console.log(`PASS ${name} (${Date.now() - startedAt}ms)`)
return
} catch (error) {
lastError = error
const signalOutput = formatSignals(signals)
if (signalOutput) {
console.error(`SIGNALS ${name}\n${signalOutput}`)
}
if (attempt >= 2 || !isRetryableTargetError(error)) {
throw error
}
console.warn(`RETRY ${name} attempt ${attempt + 1}: ${formatError(error)}`)
await delay(500)
} finally {
detachSignals()
if (!page.isClosed()) {
await page.goto('about:blank').catch(() => {})
}
}
}
throw lastError ?? new Error(`Scenario ${name} failed`)
}
async function verifyLoginSurface(page) {
console.log('STEP login-surface wait-capabilities')
const capabilitiesResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
})
console.log('STEP login-surface goto-login')
await page.goto(appUrl('/login'))
console.log('STEP login-surface capabilities-response')
const capabilitiesResponse = await resolveWaitForResponse(capabilitiesResponsePromise)
const capabilitiesPayload = await capabilitiesResponse.json()
const capabilities = capabilitiesPayload?.data ?? {}
await expect(page).toHaveTitle(new RegExp(TEXT.appTitle))
await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN')
await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible()
await expect(page.getByPlaceholder(TEXT.usernamePlaceholder)).toBeVisible()
await expect(page.getByPlaceholder(TEXT.passwordPlaceholder)).toBeVisible()
if (capabilities.email_code) {
await expect(page.getByRole('tab', { name: TEXT.emailCodeLogin })).toBeVisible()
} else {
await expect(page.getByRole('tab', { name: TEXT.emailCodeLogin })).toHaveCount(0)
}
if (capabilities.sms_code) {
await expect(page.getByRole('tab', { name: TEXT.smsCodeLogin })).toBeVisible()
} else {
await expect(page.getByRole('tab', { name: TEXT.smsCodeLogin })).toHaveCount(0)
}
if (capabilities.password_reset) {
await expect(page.getByRole('link', { name: TEXT.forgotPassword })).toBeVisible()
} else {
await expect(page.getByRole('link', { name: TEXT.forgotPassword })).toHaveCount(0)
}
await page.goto(appUrl('/dashboard'))
await expect(page).toHaveURL(/\/login$/, { timeout: 10 * 1000 })
const dashboardRedirect = await getProtectedRouteRedirect(page)
expect(dashboardRedirect.path).toBe('/login')
expect(dashboardRedirect.redirectFrom).toBe('/dashboard')
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/login$/, { timeout: 10 * 1000 })
const usersRedirect = await getProtectedRouteRedirect(page)
expect(usersRedirect.path).toBe('/login')
expect(usersRedirect.redirectFrom).toBe('/users')
}
async function verifyAuthWorkflow(page) {
logDebug('verifyAuthWorkflow: login /login')
const credentials = await loginFromLoginPage(page)
const createdUsername = `e2e_user_${Date.now()}`
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
expect(await readRefreshToken(page)).toBeTruthy()
const userRow = page.locator('tbody tr').filter({ hasText: credentials.username }).first()
await expect(userRow).toBeVisible({ timeout: 20 * 1000 })
await expect(page.getByPlaceholder(TEXT.usersFilter)).toBeVisible()
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
const userDetailTitle = page.locator('.ant-drawer-title')
await expect(userDetailTitle).toHaveText(TEXT.userDetail)
await expect(page.locator('.ant-drawer')).toContainText(TEXT.userId)
await expect(page.locator('.ant-drawer')).toContainText(credentials.username)
await page.goto(appUrl('/users'))
await expect(page.locator('tbody tr').filter({ hasText: credentials.username }).first()).toBeVisible({ timeout: 20 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.assignRolesAction }))
const assignRolesTitle = page.locator('.ant-modal-title')
await expect(assignRolesTitle).toContainText(TEXT.assignRoles)
await expect(page.locator('.ant-modal')).toContainText(credentials.username)
await page.goto(appUrl('/users'))
await expect(page.locator('tbody tr').filter({ hasText: credentials.username }).first()).toBeVisible({ timeout: 20 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser)
const createUserModal = page.locator('.ant-modal').last()
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
createdUsername,
)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserPasswordPlaceholder}"]`).first(),
'Pass123!@#',
)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
`${createdUsername}@example.com`,
)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user')
await expect(createUserModal).toHaveClass(/ant-zoom-leave/, { timeout: 20 * 1000 })
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), createdUsername)
await expect(page.locator('tbody tr').filter({ hasText: createdUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
await page.goto(appUrl('/roles'))
await expect(page).toHaveURL(/\/roles$/)
try {
await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible()
} catch (error) {
const pageText = await page.locator('body').innerText().catch(() => '')
console.error('ROLES PAGE DIAGNOSTICS', JSON.stringify({
currentUrl: page.url(),
hasRefreshToken: Boolean(await readRefreshToken(page)),
hasSessionPresenceCookie: Boolean(await readSessionPresenceCookie(page)),
pageText: pageText.slice(0, 2000),
}))
throw error
}
await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible()
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
await expect(adminRoleRow).toBeVisible({ timeout: 20 * 1000 })
await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction }))
const assignPermissionsTitle = page.locator('.ant-modal-title')
await expect(assignPermissionsTitle).toContainText(TEXT.assignPermissions)
await expect(page.locator('.ant-modal')).toContainText(TEXT.adminRoleName)
await expect(page.locator('.ant-modal')).toContainText(TEXT.permissionsHint)
await page.goto(appUrl('/roles'))
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 })
await page.goto(appUrl('/dashboard'))
await expect(page).toHaveURL(/\/dashboard$/)
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
await expect(page.getByText(TEXT.totalUsers)).toBeVisible()
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
await expect(await readRefreshToken(page)).toBeNull()
await page.goto(appUrl('/dashboard'))
const postLogoutRedirect = await getProtectedRouteRedirect(page)
expect(postLogoutRedirect.path).toBe('/login')
expect(postLogoutRedirect.redirectFrom).toBe('/dashboard')
}
async function verifyResponsiveLogin(page) {
for (const viewport of VIEWPORTS) {
logDebug(`verifyResponsiveLogin: ${viewport.name}`)
await page.setViewportSize({ width: viewport.width, height: viewport.height })
await page.goto(appUrl('/login'))
await expect(page).toHaveTitle(new RegExp(TEXT.appTitle))
await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible()
const metrics = await page.evaluate(() => {
return {
innerWidth: window.innerWidth,
bodyScrollWidth: document.body.scrollWidth,
documentScrollWidth: document.documentElement.scrollWidth,
}
})
expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.innerWidth + 24)
expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth + 24)
}
}
async function verifyDesktopAndMobileNavigation(page) {
logDebug('verifyDesktopAndMobileNavigation: login /login')
const credentials = requireEnv('E2E_LOGIN_USERNAME')
await page.setViewportSize({ width: 1440, height: 960 })
await loginFromLoginPage(page)
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.users)
await expect(page).toHaveURL(/\/users$/)
await expect(page.locator('tbody tr').filter({ hasText: credentials }).first()).toBeVisible({ timeout: 20 * 1000 })
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.roles)
await expect(page).toHaveURL(/\/roles$/)
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 })
await page.setViewportSize({ width: 390, height: 844 })
await expect
.poll(async () => await page.evaluate(() => window.innerWidth < 768))
.toBe(true)
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
await expect
.poll(async () => await page.locator('.ant-layout-header .ant-btn').count())
.toBeGreaterThan(0)
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
await expect(mobileMenuButton).toBeVisible()
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first()
await expect(mobileDashboardItem).toBeVisible()
await forceClick(mobileDashboardItem)
await expect(page).toHaveURL(/\/dashboard$/)
await page.goto(appUrl('/dashboard'))
await expect(page.locator('body')).toContainText(TEXT.todaySuccessLogins, { timeout: 10 * 1000 })
}
async function verifyUserManagementCRUD(page) {
logDebug('verifyUserManagementCRUD: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.users)
await expect(page).toHaveURL(/\/users$/)
const testUsername = `e2e_crud_${Date.now()}`
const testEmail = `${testUsername}@example.com`
const createUserModal = page.locator('.ant-modal').last()
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
testUsername,
)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserPasswordPlaceholder}"]`).first(),
'Crud123!@#',
)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
testEmail,
)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
const editResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
})
await forceClick(editDrawer.locator('.ant-btn-primary').last())
const editResponse = await resolveWaitForResponse(editResponsePromise)
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
await expect(detailDrawer).toContainText(testUsername)
await page.goto(appUrl('/users'))
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
const filteredUserRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await expect(filteredUserRow).toBeVisible({ timeout: 10 * 1000 })
await forceClick(filteredUserRow.getByRole('button', { name: TEXT.delete }))
const deleteConfirmPopover = page.locator('.ant-popconfirm').filter({ hasText: testUsername }).last()
await expect(deleteConfirmPopover).toBeVisible({ timeout: 10 * 1000 })
const deleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'DELETE'
})
await forceClick(deleteConfirmPopover.locator('.ant-btn-primary').last())
const deleteResponse = await resolveWaitForResponse(deleteResponsePromise)
await assertApiSuccessResponse(deleteResponse, 'delete user CRUD')
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toHaveCount(0, { timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyRoleManagementCRUD(page) {
logDebug('verifyRoleManagementCRUD: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.roles)
await expect(page).toHaveURL(/\/roles$/)
await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible()
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 10 * 1000 })
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction }))
const permissionsModal = page.getByRole('dialog').filter({ hasText: TEXT.assignPermissions }).last()
await expect(permissionsModal.locator('.ant-modal-title')).toContainText(TEXT.assignPermissions)
await page.goto(appUrl('/roles'))
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyPermissionsManagementCRUD(page) {
logDebug('verifyPermissionsManagementCRUD: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/permissions'))
await expect(page).toHaveURL(/\/permissions$/)
await expect(page.getByRole('heading', { name: TEXT.permissions })).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 })
await expect(page.getByRole('button', { name: TEXT.createPermission }).first()).toBeVisible()
await installFetchDiagnostics(page)
const suffix = Date.now()
const parentName = `E2E Permission ${suffix}`
const parentCode = `e2e_permission_${suffix}`
const parentPath = `/e2e/permissions/${suffix}`
const childName = `E2E Permission Child ${suffix}`
const childCode = `${parentCode}:child`
const childPath = `${parentPath}/child`
const editedParentName = `${parentName} Updated`
const editedParentPath = `${parentPath}-updated`
const createParentFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(page.getByRole('button', { name: TEXT.createPermission }).first())
const createParentModal = page.getByRole('dialog').last()
await expect(createParentModal).toBeVisible({ timeout: 10 * 1000 })
await fillPermissionModal(createParentModal, {
name: parentName,
code: parentCode,
path: parentPath,
icon: 'SafetyOutlined',
})
const createParentFetch = await submitPermissionModalAndWaitForFetch(
page,
createParentModal,
() => waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions$/) && entry.method === 'POST'
}, {
afterCount: createParentFetchCount,
label: 'create parent permission fetch',
}),
'create parent permission',
)
assertFetchLogSuccess(createParentFetch, 'create parent permission')
await waitForModalToStopBlocking(createParentModal, 'create parent permission')
await forceClick(page.locator('.ant-segmented-item').filter({ hasText: TEXT.listView }).first())
let parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first()
await expect(parentRow).toBeVisible({ timeout: 20 * 1000 })
await expect(parentRow).toContainText(parentName)
await expect(parentRow).toContainText(parentPath)
const createChildFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(parentRow.locator('.ant-btn-link').first())
const createChildModal = page.getByRole('dialog').last()
await expect(createChildModal).toBeVisible({ timeout: 10 * 1000 })
await fillPermissionModal(createChildModal, {
name: childName,
code: childCode,
path: childPath,
icon: 'ApartmentOutlined',
})
const createChildFetch = await submitPermissionModalAndWaitForFetch(
page,
createChildModal,
() => waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions$/) && entry.method === 'POST'
}, {
afterCount: createChildFetchCount,
label: 'create child permission fetch',
}),
'create child permission',
)
assertFetchLogSuccess(createChildFetch, 'create child permission')
await waitForModalToStopBlocking(createChildModal, 'create child permission')
let childRow = page.locator('tbody tr').filter({ hasText: childCode }).first()
await expect(childRow).toBeVisible({ timeout: 20 * 1000 })
await expect(childRow).toContainText(childName)
parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first()
const updateParentFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(parentRow.getByRole('button', { name: TEXT.edit }))
const editParentModal = page.getByRole('dialog').last()
await expect(editParentModal).toBeVisible({ timeout: 10 * 1000 })
await fillPermissionModal(editParentModal, {
name: editedParentName,
path: editedParentPath,
icon: 'LockOutlined',
})
const updateParentFetch = await submitPermissionModalAndWaitForFetch(
page,
editParentModal,
() => waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+$/) && entry.method === 'PUT'
}, {
afterCount: updateParentFetchCount,
label: 'update parent permission fetch',
}),
'update parent permission',
)
assertFetchLogSuccess(updateParentFetch, 'update parent permission')
await waitForModalToStopBlocking(editParentModal, 'update parent permission')
parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first()
await expect(parentRow).toContainText(editedParentName, { timeout: 20 * 1000 })
await expect(parentRow).toContainText(editedParentPath)
const disablePermissionFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(parentRow.getByRole('button', { name: TEXT.disabled }).first())
await confirmVisiblePopconfirm(page)
const disablePermissionFetch = await waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+\/status$/) && entry.method === 'PUT'
}, {
afterCount: disablePermissionFetchCount,
label: 'disable parent permission fetch',
})
assertFetchLogSuccess(disablePermissionFetch, 'disable parent permission')
parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first()
await expect(parentRow.getByRole('button', { name: TEXT.active }).first()).toBeVisible({ timeout: 20 * 1000 })
const enablePermissionFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(parentRow.getByRole('button', { name: TEXT.active }).first())
await confirmVisiblePopconfirm(page)
const enablePermissionFetch = await waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+\/status$/) && entry.method === 'PUT'
}, {
afterCount: enablePermissionFetchCount,
label: 'enable parent permission fetch',
})
assertFetchLogSuccess(enablePermissionFetch, 'enable parent permission')
parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first()
await expect(parentRow.getByRole('button', { name: TEXT.disabled }).first()).toBeVisible({ timeout: 20 * 1000 })
childRow = page.locator('tbody tr').filter({ hasText: childCode }).first()
const deleteChildFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(childRow.getByRole('button', { name: TEXT.delete }))
await confirmVisiblePopconfirm(page)
const deleteChildFetch = await waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+$/) && entry.method === 'DELETE'
}, {
afterCount: deleteChildFetchCount,
label: 'delete child permission fetch',
})
assertFetchLogSuccess(deleteChildFetch, 'delete child permission')
await expect(page.locator('tbody tr').filter({ hasText: childCode }).first()).toHaveCount(0, { timeout: 20 * 1000 })
parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first()
const deleteParentFetchCount = await getFetchDiagnosticsCount(page)
await forceClick(parentRow.getByRole('button', { name: TEXT.delete }))
await confirmVisiblePopconfirm(page)
const deleteParentFetch = await waitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+$/) && entry.method === 'DELETE'
}, {
afterCount: deleteParentFetchCount,
label: 'delete parent permission fetch',
})
assertFetchLogSuccess(deleteParentFetch, 'delete parent permission')
await expect(page.locator('tbody tr').filter({ hasText: parentCode }).first()).toHaveCount(0, { timeout: 20 * 1000 })
await forceClick(page.locator('.ant-segmented-item').filter({ hasText: TEXT.treeView }).first())
await expect(page.locator('.ant-tree')).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyUserManagementBatch(page) {
logDebug('verifyUserManagementBatch: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const batchUserA = `e2e_batch_a_${Date.now()}`
const batchUserB = `e2e_batch_b_${Date.now()}`
await createUserFromUsersPage(page, batchUserA)
await createUserFromUsersPage(page, batchUserB)
await selectUserRow(page, batchUserA)
await selectUserRow(page, batchUserB)
await expect(page.locator('body')).toContainText(TEXT.selectedUsers, { timeout: 10 * 1000 })
const batchDisableResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch/status') && response.request().method() === 'PUT'
})
await forceClick(page.getByRole('button', { name: TEXT.batchDisable }))
const batchDisableResponse = await resolveWaitForResponse(batchDisableResponsePromise)
await assertApiSuccessResponse(batchDisableResponse, 'batch disable users')
await expect(page.locator('tbody tr').filter({ hasText: batchUserA }).first()).toContainText(TEXT.disabledStatus, { timeout: 20 * 1000 })
await expect(page.locator('tbody tr').filter({ hasText: batchUserB }).first()).toContainText(TEXT.disabledStatus, { timeout: 20 * 1000 })
await selectUserRow(page, batchUserA)
await selectUserRow(page, batchUserB)
const batchEnableResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch/status') && response.request().method() === 'PUT'
})
await forceClick(page.getByRole('button', { name: TEXT.batchEnable }))
const batchEnableResponse = await resolveWaitForResponse(batchEnableResponsePromise)
await assertApiSuccessResponse(batchEnableResponse, 'batch enable users')
await expect(page.locator('tbody tr').filter({ hasText: batchUserA }).first()).toContainText(TEXT.userCreatedStatus, { timeout: 20 * 1000 })
await expect(page.locator('tbody tr').filter({ hasText: batchUserB }).first()).toContainText(TEXT.userCreatedStatus, { timeout: 20 * 1000 })
await selectUserRow(page, batchUserA)
await selectUserRow(page, batchUserB)
await forceClick(page.getByRole('button', { name: TEXT.batchDelete }))
const batchDeletePopover = page.locator('.ant-popconfirm').last()
await expect(batchDeletePopover).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE'
})
await forceClick(batchDeletePopover.locator('.ant-btn-primary').last())
const batchDeleteResponse = await resolveWaitForResponse(batchDeleteResponsePromise)
await assertApiSuccessResponse(batchDeleteResponse, 'batch delete users')
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), batchUserA)
await expect(page.locator('tbody tr').filter({ hasText: batchUserA }).first()).toHaveCount(0, { timeout: 10 * 1000 })
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), batchUserB)
await expect(page.locator('tbody tr').filter({ hasText: batchUserB }).first()).toHaveCount(0, { timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyDeviceManagement(page) {
logDebug('verifyDeviceManagement: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/devices'))
await expect(page).toHaveURL(/\/devices$/)
await expect(page.getByRole('heading', { name: TEXT.deviceManagement })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyLoginLogs(page) {
logDebug('verifyLoginLogs: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/logs/login'))
await expect(page).toHaveURL(/\/logs\/login$/)
await expect(page.getByRole('heading', { name: TEXT.loginLogs })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyOperationLogs(page) {
logDebug('verifyOperationLogs: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/logs/operation'))
await expect(page).toHaveURL(/\/logs\/operation$/)
await expect(page.getByRole('heading', { name: TEXT.operationLogs })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyWebhookManagement(page) {
logDebug('verifyWebhookManagement: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/webhooks'))
await expect(page).toHaveURL(/\/webhooks$/)
await expect(page.getByRole('heading', { name: TEXT.webhooks })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyImportExport(page) {
logDebug('verifyImportExport: login /login')
await loginFromLoginPage(page)
await page.goto(appUrl('/import-export'))
await expect(page).toHaveURL(/\/import-export$/)
await expect(page.getByRole('heading', { name: TEXT.importExport })).toBeVisible({ timeout: 10 * 1000 })
const templateResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/admin/users/import/template') && response.request().method() === 'GET'
})
await forceClick(page.getByRole('button', { name: TEXT.downloadTemplate }))
const templateResponse = await resolveWaitForResponse(templateResponsePromise)
await assertHttpOkResponse(templateResponse, 'download import template')
await forceClick(page.getByRole('tab', { name: TEXT.exportUsers }))
await expect(page.getByRole('button', { name: TEXT.exportUserData })).toBeVisible({ timeout: 10 * 1000 })
const exportResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/admin/users/export') && response.request().method() === 'GET'
})
await forceClick(page.getByRole('button', { name: TEXT.exportUserData }))
const exportResponse = await resolveWaitForResponse(exportResponsePromise)
await assertHttpOkResponse(exportResponse, 'export users')
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifySettings(page) {
logDebug('verifySettings: login /login')
await loginFromLoginPage(page)
const settingsResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/admin/settings') && response.request().method() === 'GET'
})
await page.goto(appUrl('/settings'))
await expect(page).toHaveURL(/\/settings$/)
const settingsResponse = await resolveWaitForResponse(settingsResponsePromise)
const settingsPayload = await assertApiSuccessResponse(settingsResponse, 'load settings')
await expect(page.getByRole('heading', { name: TEXT.settings })).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('body')).toContainText(TEXT.security)
await expect(page.locator('body')).toContainText(TEXT.systemInfo)
await expect(page.locator('body')).toContainText(settingsPayload.data.system.name)
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function verifyProfileAndSecurity(page) {
logDebug('verifyProfileAndSecurity: admin login /login')
await loginFromLoginPage(page)
const securityUsername = `e2e_security_${Date.now()}`
const securityPassword = 'Security123!@#'
const updatedSecurityPassword = 'Security456!@#'
const securityLandingPattern = /\/(dashboard|profile)$/
logDebug('verifyProfileAndSecurity: goto /users as admin')
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const createdUser = await createUserFromUsersPage(page, securityUsername, securityPassword)
logDebug(`verifyProfileAndSecurity: created user ${createdUser.username}`)
logDebug('verifyProfileAndSecurity: reset session before security user login')
await resetSessionToLogin(page)
logDebug(`verifyProfileAndSecurity: security user login ${createdUser.username}`)
await loginWithPassword(page, createdUser.username, securityPassword, securityLandingPattern)
await installFetchDiagnostics(page)
logDebug('verifyProfileAndSecurity: ensure /profile')
if (!/\/profile$/.test(page.url())) {
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByRole('menuitem', { name: TEXT.profile }))
}
await expect(page).toHaveURL(/\/profile$/)
await expect(page.locator('body')).toContainText(createdUser.username, { timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: open /profile/security before password change')
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByRole('menuitem', { name: TEXT.security }))
await expect(page).toHaveURL(/\/profile\/security$/)
await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 })
await forceFillInput(page.getByPlaceholder(TEXT.oldPasswordPlaceholder).first(), securityPassword)
await forceFillInput(page.getByPlaceholder(TEXT.newPasswordPlaceholder).first(), updatedSecurityPassword)
await forceFillInput(page.getByPlaceholder(TEXT.profileConfirmPasswordPlaceholder).first(), updatedSecurityPassword)
const passwordFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/users\/\d+\/password$/) && entry.method === 'PUT'
}, async () => {
await forceClick(page.getByRole('button', { name: TEXT.changePassword }).first())
})
assertFetchLogSuccess(passwordFetch, 'update profile password')
logDebug('verifyProfileAndSecurity: password updated')
logDebug('verifyProfileAndSecurity: reset session before relogin with updated password')
await resetSessionToLogin(page)
logDebug(`verifyProfileAndSecurity: relogin with updated password ${createdUser.username}`)
await loginWithPassword(page, createdUser.username, updatedSecurityPassword, securityLandingPattern)
await installFetchDiagnostics(page)
logDebug('verifyProfileAndSecurity: open /profile/security before TOTP setup')
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByRole('menuitem', { name: TEXT.security }))
await expect(page).toHaveURL(/\/profile\/security$/)
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.enableTOTP }).first())
const setupModal = page.locator('.ant-modal').last()
await expect(setupModal).toBeVisible({ timeout: 10 * 1000 })
await expect(setupModal.locator('img[alt="TOTP QR Code"]')).toBeVisible({ timeout: 10 * 1000 })
const setupModalText = await setupModal.innerText()
const totpSecret = extractTotpSecret(setupModalText)
const recoveryCodes = await getRecoveryCodesFromTotpModal(setupModal)
logDebug(`verifyProfileAndSecurity: extracted TOTP secret and ${recoveryCodes.length} recovery codes`)
await forceFillInput(setupModal.locator('input[maxLength="6"]').first(), generateTotpCode(totpSecret))
logDebug('verifyProfileAndSecurity: submit TOTP enable')
const enableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/enable$/) && entry.method === 'POST'
}, async () => {
await forceClick(setupModal.getByRole('button', { name: TEXT.confirmEnableTOTP }).last())
})
assertFetchLogSuccess(enableTotpFetch, 'enable TOTP')
await waitForModalToStopBlocking(setupModal, 'enable TOTP')
await expect(page.getByRole('button', { name: TEXT.disableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP enabled')
await forceClick(page.getByRole('button', { name: TEXT.disableTOTP }).first())
const disableModal = page.locator('.ant-modal').last()
await expect(disableModal).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: submit TOTP disable')
await forceFillInput(disableModal.locator('input').first(), recoveryCodes[0])
const disableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/disable$/) && entry.method === 'POST'
}, async () => {
await forceClick(disableModal.getByRole('button', { name: TEXT.confirmDisableTOTP }).last())
})
assertFetchLogSuccess(disableTotpFetch, 'disable TOTP')
await waitForModalToStopBlocking(disableModal, 'disable TOTP')
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP disabled')
await resetSessionToLogin(page)
logDebug('verifyProfileAndSecurity: completed')
}
async function verifyDashboardStats(page) {
logDebug('verifyDashboardStats: login /login')
await loginFromLoginPage(page)
await expect(page).toHaveURL(/\/dashboard$/)
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.getByText(TEXT.totalUsers)).toBeVisible()
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
}
async function main() {
let browser = null
let runtime = null
let managedBrowser = null
let managedProfileDir = null
const selectedScenarioNames = new Set(
(process.env.E2E_SCENARIOS ?? '')
.split(',')
.map((name) => name.trim())
.filter(Boolean),
)
if (process.env.E2E_MANAGED_BROWSER === '1') {
const browserPath = await resolveManagedBrowserPath()
const port = await getFreePort()
managedProfileDir = await createManagedBrowserProfileDir(browserPath, port)
managedBrowser = startManagedBrowser(browserPath, port, managedProfileDir)
managedCdpUrl = `http://127.0.0.1:${port}`
console.log(`LAUNCH playwright-cdp ${browserPath}`)
await waitForJson(`${managedCdpUrl}/json/version`, STARTUP_TIMEOUT_MS, 'managed browser CDP endpoint')
}
console.log('CONNECT playwright-cdp')
browser = await connectBrowserWithRetry()
try {
console.log('CONNECTED playwright-cdp')
runtime = {
browser,
context: browser.contexts()[0] ?? null,
}
if (!runtime.context) {
throw new Error('No persistent Chromium context is available through CDP.')
}
const scenarios = []
if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') {
scenarios.push(['admin-bootstrap', verifyAdminBootstrapWorkflow])
}
scenarios.push(
['public-registration', verifyPublicRegistration],
['email-activation', verifyEmailActivationWorkflow],
['password-reset', verifyPasswordResetWorkflow],
['login-surface', verifyLoginSurface],
['auth-workflow', verifyAuthWorkflow],
['responsive-login', verifyResponsiveLogin],
['desktop-mobile-navigation', verifyDesktopAndMobileNavigation],
['user-management-crud', verifyUserManagementCRUD],
['user-management-batch', verifyUserManagementBatch],
['role-management-crud', verifyRoleManagementCRUD],
['permissions-management-crud', verifyPermissionsManagementCRUD],
['device-management', verifyDeviceManagement],
['login-logs', verifyLoginLogs],
['operation-logs', verifyOperationLogs],
['webhook-management', verifyWebhookManagement],
['import-export', verifyImportExport],
['profile-and-security', verifyProfileAndSecurity],
['settings', verifySettings],
['dashboard-stats', verifyDashboardStats],
)
const scenariosToRun = selectedScenarioNames.size === 0
? scenarios
: scenarios.filter(([name]) => name === 'admin-bootstrap' || selectedScenarioNames.has(name))
if (scenariosToRun.length === 0) {
throw new Error(`No E2E scenarios matched E2E_SCENARIOS=${process.env.E2E_SCENARIOS ?? ''}`)
}
console.log(`SCENARIOS ${scenariosToRun.map(([name]) => name).join(', ')}`)
for (const [name, fn] of scenariosToRun) {
await runScenario(runtime, name, fn)
}
console.log('Playwright CDP E2E completed successfully')
} finally {
await (runtime?.browser ?? browser)?.close().catch(() => {})
await killManagedBrowser(managedBrowser)
if (managedProfileDir) {
await rm(managedProfileDir, { recursive: true, force: true }).catch(() => {})
}
managedCdpUrl = null
}
}
await main().catch((error) => {
console.error(error && error.stack ? error.stack : error)
process.exitCode = 1
})