1177 lines
40 KiB
JavaScript
1177 lines
40 KiB
JavaScript
import process from 'node:process'
|
|
import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
import { constants as fsConstants } from 'node:fs'
|
|
import { spawn } from 'node:child_process'
|
|
import 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
|
|
})
|