- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量 - AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量 - 在 userRepositoryInterface 补充 GetByIDs 方法签名
2587 lines
96 KiB
JavaScript
2587 lines
96 KiB
JavaScript
import process from 'node:process'
|
|
import { access, 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'
|
|
|
|
import { parseSelectedScenarioNames, selectScenarioNames } from './playwright-e2e-scenarios.mjs'
|
|
|
|
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',
|
|
profileBioPlaceholder: '\u4ecb\u7ecd\u4e00\u4e0b\u81ea\u5df1...',
|
|
profileNicknamePlaceholder: '\u8bf7\u8f93\u5165\u6635\u79f0',
|
|
profileRegionPlaceholder: '\u8bf7\u8f93\u5165\u5730\u533a',
|
|
profileSaveChanges: '\u4fdd\u5b58\u4fee\u6539',
|
|
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() {
|
|
return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-'))
|
|
}
|
|
|
|
function startManagedBrowser(browserPath, port, profileDir) {
|
|
const args = [
|
|
`--remote-debugging-port=${port}`,
|
|
`--user-data-dir=${profileDir}`,
|
|
'--noerrdialogs',
|
|
'--no-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-background-networking',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-breakpad',
|
|
'--disable-crash-reporter',
|
|
'--disable-crashpad-for-testing',
|
|
'--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) && !(element instanceof HTMLTextAreaElement)) {
|
|
throw new Error('Target element is not a text 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 verifyProfileManagement(page) {
|
|
logDebug('verifyProfileManagement: admin login /login')
|
|
await loginFromLoginPage(page)
|
|
|
|
const profileUsername = `e2e_profile_${Date.now()}`
|
|
const profilePassword = 'Profile123!@#'
|
|
const profileLandingPattern = /\/profile$/
|
|
const suffix = Date.now()
|
|
const updatedNickname = `Profile User ${suffix}`
|
|
const updatedRegion = `Hangzhou-${suffix}`
|
|
|
|
logDebug('verifyProfileManagement: goto /users as admin')
|
|
await page.goto(appUrl('/users'))
|
|
await expect(page).toHaveURL(/\/users$/)
|
|
const createdUser = await createUserFromUsersPage(page, profileUsername, profilePassword)
|
|
logDebug(`verifyProfileManagement: created user ${createdUser.username}`)
|
|
|
|
logDebug('verifyProfileManagement: reset session before profile user login')
|
|
await resetSessionToLogin(page)
|
|
|
|
logDebug(`verifyProfileManagement: profile user login ${createdUser.username}`)
|
|
await loginWithPassword(page, createdUser.username, profilePassword, profileLandingPattern)
|
|
await installFetchDiagnostics(page)
|
|
|
|
await expect(page).toHaveURL(/\/profile$/)
|
|
await expect(page.getByRole('heading', { name: TEXT.profile })).toBeVisible({ timeout: 10 * 1000 })
|
|
await expect(page.locator('body')).toContainText(createdUser.username, { timeout: 10 * 1000 })
|
|
await expect(page.locator('body')).toContainText(createdUser.email)
|
|
|
|
await forceFillInput(page.getByPlaceholder(TEXT.profileNicknamePlaceholder).first(), updatedNickname)
|
|
await forceFillInput(page.getByPlaceholder(TEXT.profileRegionPlaceholder).first(), updatedRegion)
|
|
await forceFillInput(page.getByPlaceholder(TEXT.profileBioPlaceholder).first(), `Profile bio ${suffix}`)
|
|
|
|
const updateProfileFetchCount = await getFetchDiagnosticsCount(page)
|
|
const updateProfileFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
|
|
return fetchLogPathMatches(entry, /\/api\/v1\/users\/\d+$/) && entry.method === 'PUT'
|
|
}, async () => {
|
|
await forceClick(page.getByRole('button', { name: TEXT.profileSaveChanges }).first())
|
|
}, {
|
|
afterCount: updateProfileFetchCount,
|
|
label: 'update profile fetch',
|
|
})
|
|
assertFetchLogSuccess(updateProfileFetch, 'update profile')
|
|
|
|
await expect(page.getByPlaceholder(TEXT.profileNicknamePlaceholder).first()).toHaveValue(updatedNickname, { timeout: 20 * 1000 })
|
|
await expect(page.getByPlaceholder(TEXT.profileRegionPlaceholder).first()).toHaveValue(updatedRegion, { timeout: 20 * 1000 })
|
|
await expect(page.getByPlaceholder(TEXT.profileBioPlaceholder).first()).toHaveValue(`Profile bio ${suffix}`)
|
|
|
|
await forceClick(page.locator('a[href="/profile/security"]').first())
|
|
await expect(page).toHaveURL(/\/profile\/security$/)
|
|
await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 })
|
|
|
|
await resetSessionToLogin(page)
|
|
logDebug('verifyProfileManagement: completed')
|
|
}
|
|
|
|
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 = parseSelectedScenarioNames(process.env.E2E_SCENARIOS ?? '')
|
|
const scenarioEntries = new Map([
|
|
['admin-bootstrap', verifyAdminBootstrapWorkflow],
|
|
['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-management', verifyProfileManagement],
|
|
['profile-and-security', verifyProfileAndSecurity],
|
|
['settings', verifySettings],
|
|
['dashboard-stats', verifyDashboardStats],
|
|
])
|
|
const scenarioNamesToRun = selectScenarioNames({
|
|
requestedScenarioNames: selectedScenarioNames,
|
|
expectAdminBootstrap: process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1',
|
|
})
|
|
|
|
if (process.env.E2E_LIST_SCENARIOS === '1') {
|
|
console.log(scenarioNamesToRun.join('\n'))
|
|
return
|
|
}
|
|
|
|
if (process.env.E2E_MANAGED_BROWSER === '1') {
|
|
const browserPath = await resolveManagedBrowserPath()
|
|
const port = await getFreePort()
|
|
managedProfileDir = await createManagedBrowserProfileDir()
|
|
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 scenariosToRun = scenarioNamesToRun.map((name) => {
|
|
const handler = scenarioEntries.get(name)
|
|
if (!handler) {
|
|
throw new Error(`No Playwright CDP scenario handler is registered for ${name}.`)
|
|
}
|
|
return [name, handler]
|
|
})
|
|
|
|
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
|
|
})
|