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' const SIDEBAR_GROUP_TEST_IDS = new Map([ [TEXT.accessControl, 'nav-group-access-control'], ]) const SIDEBAR_MENU_TEST_IDS = new Map([ [TEXT.dashboard, 'nav-dashboard'], [TEXT.users, 'nav-users'], [TEXT.roles, 'nav-roles'], ]) 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, } }) } function getSidebarMenuLocator(page, label) { const testId = SIDEBAR_MENU_TEST_IDS.get(label) if (testId) { return page.locator(`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`) } return page .locator('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item') .filter({ hasText: label }) } function getSidebarGroupLocator(page, label) { const testId = SIDEBAR_GROUP_TEST_IDS.get(label) if (testId) { return page.locator( `.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`, ) } return page .locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title') .filter({ hasText: label }) } async function clickSidebarMenu(page, label) { const menuItems = getSidebarMenuLocator(page, label) await expect.poll(async () => await menuItems.count()).toBeGreaterThan(0) const count = await menuItems.count() for (let index = 0; index < count; index += 1) { const menuItem = menuItems.nth(index) if (await menuItem.isVisible()) { try { await menuItem.scrollIntoViewIfNeeded() await menuItem.click({ force: true, timeout: 5_000 }) } catch { 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.getByTestId('mobile-nav-trigger') if (!(await mobileMenuButton.isVisible().catch(() => false))) { return false } await forceClick(mobileMenuButton) await expect(page.locator('.ant-drawer.ant-drawer-open .ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 }) return true } async function expandSidebarGroup(page, label) { const groups = getSidebarGroupLocator(page, label) await expect.poll(async () => await groups.count()).toBeGreaterThan(0) const findVisibleGroup = async () => { 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) { const isExpanded = await group.evaluate((element) => { return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false }) if (!isExpanded) { try { await group.scrollIntoViewIfNeeded() await group.click({ force: true, timeout: 5_000 }) } catch { await forceClick(group) } await expect.poll(async () => { return await group.evaluate((element) => { return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false }) }).toBe(true) } 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-drawer-open .ant-menu-submenu-title', ), menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .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 createUserButton = page.getByRole('button', { name: TEXT.createUser }).first() const createUserModal = page.locator('.ant-modal').last() const createUserRow = page.locator('tbody tr').filter({ hasText: username }).first() logDebug(`createUserFromUsersPage: open modal for ${username}`) await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 }) await forceClick(createUserButton) await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser) await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 }) logDebug(`createUserFromUsersPage: modal visible for ${username}`) 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, ) const submitButton = createUserModal.getByRole('button', { name: TEXT.createUser }).last() const submitStrategies = [ async () => { await forceClick(submitButton) }, async () => { await submitButton.evaluate((element) => { if (!(element instanceof HTMLButtonElement) && !(element instanceof HTMLElement)) { throw new Error('Create user submit target is not clickable.') } element.click() }) }, async () => { await forceClick(submitButton) }, ] let createUserResponseResult = { error: new Error('create user request was not attempted') } for (let index = 0; index < submitStrategies.length; index += 1) { logDebug(`createUserFromUsersPage: submit modal for ${username} attempt ${index + 1}`) const responsePromise = waitForResponseSafe(page, (response) => { return response.url().includes('/api/v1/users') && response.request().method() === 'POST' }, { timeout: 8 * 1000 }) await submitStrategies[index]() createUserResponseResult = await responsePromise if (createUserResponseResult.response) { await assertApiSuccessResponse(createUserResponseResult.response, `create user ${username}`) logDebug(`createUserFromUsersPage: response ok for ${username}`) break } const rowVisibleAfterSubmit = await createUserRow.isVisible().catch(() => false) if (rowVisibleAfterSubmit) { logDebug(`createUserFromUsersPage: row became visible without captured response for ${username}`) break } logDebug(`createUserFromUsersPage: submit attempt ${index + 1} did not complete for ${username}`) } try { await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 }) } catch (rowError) { if (!createUserResponseResult.error) { throw rowError } 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, modalText: visibleText('.ant-modal'), formErrors: visibleText('.ant-form-item-explain-error'), toastMessages: visibleText('.ant-message .ant-message-notice-content'), primaryButtons: visibleText('.ant-modal .ant-btn-primary'), } }) throw new Error( `create user ${username} did not complete. responseError=${formatError(createUserResponseResult.error)} diagnostics=${JSON.stringify(diagnostics)}`, ) } if (createUserResponseResult.error) { logDebug(`createUserFromUsersPage: row visible without captured response for ${username}`) } await page.goto(appUrl('/users')) await expect(page).toHaveURL(/\/users$/) await expect(createUserRow).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 agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first() await forceClick(agreementCheckbox) await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 }) 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 agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first() await forceClick(agreementCheckbox) await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 }) 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.getByTestId('mobile-nav-trigger').count()) .toBeGreaterThan(0) const mobileMenuButton = page.getByTestId('mobile-nav-trigger') await expect(mobileMenuButton).toBeVisible() await forceClick(mobileMenuButton) const openDrawer = page.locator('.ant-drawer.ant-drawer-open') await expect(openDrawer.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 }) const mobileDashboardItem = openDrawer.getByTestId('nav-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 page.goto(appUrl('/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 page.goto(appUrl('/users')) await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 }) let userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first() await forceClick(userRow.getByRole('button', { name: TEXT.edit })) const editDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.editUser }).last() await expect(editDrawerTitle).toBeVisible({ timeout: 10 * 1000 }) const editDrawer = page.locator('.ant-drawer.ant-drawer-open').last() 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 page.goto(appUrl('/users')) userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first() await expect(userRow).toBeVisible({ timeout: 20 * 1000 }) await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction })) const detailDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.userDetail }).last() await expect(detailDrawerTitle).toBeVisible({ timeout: 10 * 1000 }) await expect(page.locator('.ant-drawer')).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 batchDeleteModal = page.locator('.ant-modal').last() await expect(batchDeleteModal).toBeVisible({ timeout: 10 * 1000 }) const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => { return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE' }) await forceClick(batchDeleteModal.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 setupModalRoot = page.locator('.ant-modal-root').filter({ has: page.getByRole('button', { name: TEXT.confirmEnableTOTP }), }).last() const setupModal = setupModalRoot.locator('.ant-modal').first() 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(setupModalRoot, 'enable TOTP') await expect(setupModalRoot).toBeHidden({ timeout: 10 * 1000 }) 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 disableModalRoot = page.locator('.ant-modal-root').filter({ has: page.getByRole('button', { name: TEXT.confirmDisableTOTP }), }).last() const disableModal = disableModalRoot.locator('.ant-modal').first() await expect(disableModal).toBeVisible({ timeout: 10 * 1000 }) logDebug('verifyProfileAndSecurity: submit TOTP disable') const disableCodeInput = disableModal.locator('input').first() await expect(disableCodeInput).toBeVisible({ timeout: 10 * 1000 }) await forceFillInput(disableCodeInput, 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(disableModalRoot, 'disable TOTP') await expect(disableModalRoot).toBeHidden({ timeout: 10 * 1000 }) 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 })