Files
user-system/frontend/admin/scripts/run-playwright-cdp-e2e.mjs

1177 lines
40 KiB
JavaScript
Raw Normal View History

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
})