import process from 'node:process' import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { constants as fsConstants } from 'node:fs' import { spawn } from 'node:child_process' import net from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' import { chromium, expect } from '@playwright/test' const TEXT = { accessControl: '\u8bbf\u95ee\u63a7\u5236', adminBootstrapTitle: '\u7cfb\u7edf\u5c1a\u672a\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7', adminRoleName: '\u7ba1\u7406\u5458', 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', bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf', bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d', 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', dashboard: '\u603b\u89c8', emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801', emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f', forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f', loginAction: '\u767b\u5f55', loginNow: '\u7acb\u5373\u767b\u5f55', logout: '\u9000\u51fa\u767b\u5f55', passwordPlaceholder: '\u5bc6\u7801', permissionsAction: '\u6743\u9650', permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650', profile: '\u4e2a\u4eba\u8d44\u6599', 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', smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801', todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55', totalUsers: '\u7528\u6237\u603b\u6570', 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', 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 ?? 30000) const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim() const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present' let managedCdpUrl = null function appUrl(pathname) { return new URL(pathname, `${BASE_URL}/`).toString() } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function 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 } 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}.`) } 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 { consoleErrors: [], dialogs: [], pageErrors: [], popups: [], requestFailures: [], unauthorizedResponses: [], windowGuardEvents: [], } } function logDebug(message) { if (DEBUG) { console.log(`[debug] ${message}`) } } function formatError(error) { if (!error) { return 'unknown error' } if (error instanceof Error) { return error.message || error.name } return String(error) } function isRetryableTargetError(error) { const message = formatError(error) return ( message.includes('Target page, context or browser has been closed') || message.includes('Target closed') || message.includes('Browser has been closed') ) } async function assertFileExists(filePath) { await access(filePath, fsConstants.F_OK) } function isHeadlessShellBrowser(browserPath) { return path.basename(browserPath).toLowerCase().includes('headless-shell') } async function resolveManagedBrowserPath() { const envCandidates = [ process.env.E2E_BROWSER_PATH, process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, process.env.CHROME_HEADLESS_SHELL_PATH, ] .map((value) => (value ?? '').trim()) .filter(Boolean) for (const candidate of envCandidates) { await assertFileExists(candidate) return candidate } for (const candidate of [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', ]) { try { await assertFileExists(candidate) return candidate } catch { continue } } const baseDir = path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright') const candidates = [] try { const entries = await readdir(baseDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory() || !entry.name.startsWith('chromium_headless_shell-')) { continue } candidates.push( path.join(baseDir, entry.name, 'chrome-headless-shell-win64', 'chrome-headless-shell.exe'), ) } } catch { throw new Error('failed to scan Playwright browser cache under LOCALAPPDATA') } candidates.sort().reverse() for (const candidate of candidates) { try { await assertFileExists(candidate) return candidate } catch { continue } } throw new Error('No compatible browser found for Playwright CDP E2E.') } async function createManagedBrowserProfileDir(browserPath, port) { if (!isHeadlessShellBrowser(browserPath)) { return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-')) } const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles') await mkdir(profileRoot, { recursive: true }) return path.join(profileRoot, `pw-profile-playwright-cdp-${port}`) } function startManagedBrowser(browserPath, port, profileDir) { const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox'] if (isHeadlessShellBrowser(browserPath)) { args.push('--single-process') } else { args.push( '--disable-dev-shm-usage', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-sync', '--headless=new', ) } args.push('about:blank') const 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 formatSignals(signals) { const lines = [] 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.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.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 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 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() === 401) { signals.unauthorizedResponses.push(`${response.request().method()} ${response.url()}`) } } page.on('console', onConsole) page.on('dialog', onDialog) page.on('pageerror', onPageError) page.on('popup', onPopup) page.on('requestfailed', onRequestFailed) page.on('response', onResponse) return () => { page.off('console', onConsole) page.off('dialog', onDialog) page.off('pageerror', onPageError) page.off('popup', onPopup) page.off('requestfailed', onRequestFailed) page.off('response', onResponse) } } async function resetBrowserState(context, page) { logDebug('resetting browser state') await context.clearCookies() 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()) } 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 ensurePersistentPage(browser, context) { let page = context.pages().find((candidate) => !candidate.isClosed()) if (page) { return page } 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) { page = context.pages().find((candidate) => !candidate.isClosed()) if (page) { return page } await delay(100) } return null } async function getProtectedRouteRedirect(page) { return await page.evaluate(() => { return { path: window.location.pathname, redirectFrom: history.state?.usr?.from?.pathname ?? null, title: document.title, } }) } async function clickSidebarMenu(page, label) { const menuItems = page .locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item') .filter({ hasText: label }) const count = await menuItems.count() for (let index = 0; index < count; index += 1) { const menuItem = menuItems.nth(index) if (await menuItem.isVisible()) { await forceClick(menuItem) return } } throw new Error(`No visible menu item found for ${label}.`) } async function expandSidebarGroup(page, label) { const groups = page .locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title') .filter({ hasText: label }) const count = await groups.count() for (let index = 0; index < count; index += 1) { const group = groups.nth(index) if (await group.isVisible()) { await forceClick(group) return } } throw new Error(`No visible menu group found for ${label}.`) } async function forceFillInput(locator, value) { await expect(locator).toBeVisible() await locator.evaluate((element, nextValue) => { if (!(element instanceof HTMLInputElement)) { throw new Error('Target element is not an input.') } element.focus() const prototype = Object.getPrototypeOf(element) const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value') if (descriptor?.set) { descriptor.set.call(element, nextValue) } else { element.value = nextValue } element.dispatchEvent(new Event('input', { bubbles: true })) element.dispatchEvent(new Event('change', { bubbles: true })) }, value) } async function forceClick(locator) { await expect(locator).toBeVisible() 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 page.evaluate((cookieName) => { const target = `${cookieName}=` const matched = document.cookie .split(';') .map((cookie) => cookie.trim()) .find((cookie) => cookie.startsWith(target)) return matched ? matched.slice(target.length) : null }, SESSION_PRESENCE_COOKIE_NAME) } 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 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]') await forceFillInput(usernameInput, username) await forceFillInput(loginForm.locator('input[type="password"]').first(), password) const loginResponsePromise = page.waitForResponse((response) => { return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST' }, { timeout: 5_000 }).catch(() => null) await forceClick(loginForm.locator('button[type="submit"]').first()) const loginResponse = await loginResponsePromise if (loginResponse) { await assertApiSuccessResponse(loginResponse, 'password login') } if (expectedUrlPattern) { await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 }) } } 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$/) return { username, password } } 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 capabilitiesResponse = page.waitForResponse((response) => { return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET' }) await page.goto(appUrl('/login')) 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.bootstrapAdminPasswordPlaceholder}"]`).first(), password) await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminConfirmPasswordPlaceholder}"]`).first(), password) const [bootstrapResponse] = await Promise.all([ page.waitForResponse((response) => { return response.url().includes('/api/v1/auth/bootstrap-admin') && response.request().method() === 'POST' }), forceClick(page.getByRole('button', { name: TEXT.bootstrapAdminSubmit })), ]) await assertApiSuccessResponse(bootstrapResponse, 'bootstrap admin') 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 [registerResponse] = await Promise.all([ page.waitForResponse((response) => { return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST' }), forceClick(page.getByRole('button', { name: TEXT.createAccount })), ]) 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 [registerResponse] = await Promise.all([ page.waitForResponse((response) => { return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST' }), forceClick(page.getByRole('button', { name: TEXT.createAccount })), ]) 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 [activationResponse] = await Promise.all([ page.waitForResponse((response) => { return response.url().includes('/api/v1/auth/activate') && response.request().method() === 'GET' }), page.goto(activationLink), ]) 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 runScenario(browser, context, name, fn) { console.log(`START ${name}`) let lastError = null for (let attempt = 1; attempt <= 2; attempt += 1) { const activeContext = browser.contexts()[0] ?? context const page = await ensurePersistentPage(browser, activeContext) if (!page) { throw new Error('No persistent page is available in the Chromium CDP context.') } 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 capabilitiesResponse = page.waitForResponse((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 capabilitiesPayload = await (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 = page.waitForResponse((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 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$/) await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible() await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible() const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first() await expect(adminRoleRow).toBeVisible({ timeout: 20 * 1000 }) await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction })) const assignPermissionsTitle = page.locator('.ant-modal-title') await expect(assignPermissionsTitle).toContainText(TEXT.assignPermissions) await expect(page.locator('.ant-modal')).toContainText(TEXT.adminRoleName) await expect(page.locator('.ant-modal')).toContainText(TEXT.permissionsHint) await page.goto(appUrl('/roles')) await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 }) await page.goto(appUrl('/dashboard')) await expect(page).toHaveURL(/\/dashboard$/) await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible() await expect(page.getByText(TEXT.totalUsers)).toBeVisible() await forceClick(page.locator('[class*="userTrigger"]')) await forceClick(page.getByText(TEXT.logout, { exact: true })) await expect(page).toHaveURL(/\/login$/) await expect(await readRefreshToken(page)).toBeNull() await page.goto(appUrl('/dashboard')) const postLogoutRedirect = await getProtectedRouteRedirect(page) expect(postLogoutRedirect.path).toBe('/login') expect(postLogoutRedirect.redirectFrom).toBe('/dashboard') } async function verifyResponsiveLogin(page) { for (const viewport of VIEWPORTS) { logDebug(`verifyResponsiveLogin: ${viewport.name}`) await page.setViewportSize({ width: viewport.width, height: viewport.height }) await page.goto(appUrl('/login')) await expect(page).toHaveTitle(new RegExp(TEXT.appTitle)) await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible() const metrics = await page.evaluate(() => { return { innerWidth: window.innerWidth, bodyScrollWidth: document.body.scrollWidth, documentScrollWidth: document.documentElement.scrollWidth, } }) expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.innerWidth + 24) expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth + 24) } } async function verifyDesktopAndMobileNavigation(page) { logDebug('verifyDesktopAndMobileNavigation: login /login') const credentials = requireEnv('E2E_LOGIN_USERNAME') await page.setViewportSize({ width: 1440, height: 960 }) await loginFromLoginPage(page) await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible() await expandSidebarGroup(page, TEXT.accessControl) await clickSidebarMenu(page, TEXT.users) await expect(page).toHaveURL(/\/users$/) await expect(page.locator('tbody tr').filter({ hasText: credentials }).first()).toBeVisible({ timeout: 20 * 1000 }) await expandSidebarGroup(page, TEXT.accessControl) await clickSidebarMenu(page, TEXT.roles) await expect(page).toHaveURL(/\/roles$/) await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 }) await page.setViewportSize({ width: 390, height: 844 }) await expect .poll(async () => await page.evaluate(() => window.innerWidth < 768)) .toBe(true) await page.evaluate(() => window.dispatchEvent(new Event('resize'))) await expect .poll(async () => await page.locator('.ant-layout-header .ant-btn').count()) .toBeGreaterThan(0) const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first() await expect(mobileMenuButton).toBeVisible() await forceClick(mobileMenuButton) await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 }) const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first() await expect(mobileDashboardItem).toBeVisible() await forceClick(mobileDashboardItem) await expect(page).toHaveURL(/\/dashboard$/) await page.goto(appUrl('/dashboard')) await expect(page.locator('body')).toContainText(TEXT.todaySuccessLogins, { timeout: 10 * 1000 }) } async function main() { let browser = null let managedBrowser = null let managedProfileDir = null if (process.env.E2E_MANAGED_BROWSER === '1') { const browserPath = await resolveManagedBrowserPath() const port = await getFreePort() managedProfileDir = await createManagedBrowserProfileDir(browserPath, port) managedBrowser = startManagedBrowser(browserPath, port, managedProfileDir) managedCdpUrl = `http://127.0.0.1:${port}` console.log(`LAUNCH playwright-cdp ${browserPath}`) await waitForJson(`${managedCdpUrl}/json/version`, STARTUP_TIMEOUT_MS, 'managed browser CDP endpoint') } console.log('CONNECT playwright-cdp') browser = await connectBrowserWithRetry() try { console.log('CONNECTED playwright-cdp') const context = browser.contexts()[0] if (!context) { throw new Error('No persistent Chromium context is available through CDP.') } if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') { await runScenario(browser, context, 'admin-bootstrap', verifyAdminBootstrapWorkflow) } await runScenario(browser, context, 'public-registration', verifyPublicRegistration) await runScenario(browser, context, 'email-activation', verifyEmailActivationWorkflow) await runScenario(browser, context, 'login-surface', verifyLoginSurface) await runScenario(browser, context, 'auth-workflow', verifyAuthWorkflow) await runScenario(browser, context, 'responsive-login', verifyResponsiveLogin) await runScenario(browser, context, 'desktop-mobile-navigation', verifyDesktopAndMobileNavigation) console.log('Playwright CDP E2E completed successfully') } finally { await 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 })