fix: v6 code review P0 auth/IDOR fixes + frontend regression patches
Backend fixes: - auth_handler: P0 认证逻辑修复 - ratelimit: 限速中间件增强 + 新增单元测试 - auth_service: 认证服务逻辑完善 + 新增测试 - server: server 配置增强 + 新增测试 - handler_test: 新增 handler 层集成测试 - auth_bootstrap_test: bootstrap 路径测试 Frontend patches: - LoginPage/RegisterPage: CSRF + 表单交互修复 - BootstrapAdminPage: 引导流程修复 - DevicesPage: 设备管理页修复 - auth/social-accounts/users/webhooks services: 类型修正 - csrf.ts: CSRF token 处理修正 - E2E 脚本: CDP smoke + auth e2e 增强 Docs: - FULL_CODE_REVIEW_REPORT_2026-04-20 - report-v6 执行计划 - REAL_PROJECT_STATUS 更新 - .gitignore: 新增 .gocache-*/config.yaml 排除 验证: go build/vet 0错误, go test 42/42 PASS, 0 FAIL
This commit is contained in:
28
frontend/admin/scripts/cdp-connect-probe.mjs
Normal file
28
frontend/admin/scripts/cdp-connect-probe.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import process from 'node:process'
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const cdpBaseUrl = (process.env.E2E_PLAYWRIGHT_CDP_URL ?? process.env.E2E_CDP_BASE_URL ?? '').trim()
|
||||
|
||||
if (!cdpBaseUrl) {
|
||||
throw new Error('E2E_PLAYWRIGHT_CDP_URL or E2E_CDP_BASE_URL is required')
|
||||
}
|
||||
|
||||
console.log(`PROBE cdp=${cdpBaseUrl}`)
|
||||
|
||||
if (process.env.PROBE_PRECREATE_TARGET === '1') {
|
||||
console.log('PROBE precreate-target=start')
|
||||
await fetch(`${cdpBaseUrl}/json/new?about:blank`, { method: 'PUT' }).catch(async () => {
|
||||
await fetch(`${cdpBaseUrl}/json/new?about:blank`)
|
||||
})
|
||||
console.log('PROBE precreate-target=done')
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(cdpBaseUrl)
|
||||
console.log(`PROBE connected contexts=${browser.contexts().length}`)
|
||||
|
||||
for (const [index, context] of browser.contexts().entries()) {
|
||||
console.log(`PROBE context[${index}] pages=${context.pages().length}`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
console.log('PROBE done')
|
||||
@@ -383,6 +383,7 @@ try {
|
||||
Write-Host "Launching command: $commandName $($commandArgs -join ' ')"
|
||||
& $commandName @commandArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Show-BrowserLogs $browserHandle
|
||||
throw "command failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -9,19 +9,58 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
|
||||
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
|
||||
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
|
||||
$goPathDir = Join-Path $tempCacheRoot 'gopath'
|
||||
function Resolve-E2ERoots {
|
||||
$scriptFrontendRoot = Resolve-Path (Join-Path $PSScriptRoot '..') -ErrorAction SilentlyContinue
|
||||
$scriptProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') -ErrorAction SilentlyContinue
|
||||
$cwdFrontendRoot = Resolve-Path (Get-Location).Path
|
||||
$cwdProjectRoot = Resolve-Path (Join-Path $cwdFrontendRoot '..\..') -ErrorAction SilentlyContinue
|
||||
|
||||
if (
|
||||
$scriptFrontendRoot -and
|
||||
$scriptProjectRoot -and
|
||||
(Test-Path (Join-Path $scriptFrontendRoot 'package.json')) -and
|
||||
(Test-Path (Join-Path $scriptProjectRoot 'go.mod'))
|
||||
) {
|
||||
return [pscustomobject]@{
|
||||
FrontendRoot = $scriptFrontendRoot.Path
|
||||
ProjectRoot = $scriptProjectRoot.Path
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$cwdProjectRoot -and
|
||||
(Test-Path (Join-Path $cwdFrontendRoot 'package.json')) -and
|
||||
(Test-Path (Join-Path $cwdProjectRoot 'go.mod'))
|
||||
) {
|
||||
return [pscustomobject]@{
|
||||
FrontendRoot = $cwdFrontendRoot
|
||||
ProjectRoot = $cwdProjectRoot.Path
|
||||
}
|
||||
}
|
||||
|
||||
throw 'failed to resolve frontend/project roots for playwright e2e'
|
||||
}
|
||||
|
||||
$resolvedRoots = Resolve-E2ERoots
|
||||
$projectRoot = $resolvedRoots.ProjectRoot
|
||||
$frontendRoot = $resolvedRoots.FrontendRoot
|
||||
$serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
|
||||
$e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N'))
|
||||
$goCacheDir = Join-Path $e2eRunRoot 'go-build'
|
||||
$goModCacheDir = Join-Path $e2eRunRoot 'gomod'
|
||||
$goPathDir = Join-Path $e2eRunRoot 'gopath'
|
||||
$e2eDataRoot = Join-Path $e2eRunRoot 'data'
|
||||
$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db'
|
||||
$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl'
|
||||
$e2eConfigPath = Join-Path $e2eRunRoot 'config.yaml'
|
||||
$bootstrapSecret = 'e2e-bootstrap-secret'
|
||||
|
||||
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null
|
||||
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eRunRoot, $e2eDataRoot | Out-Null
|
||||
Set-Content -Path $e2eConfigPath -Encoding utf8 -Value @(
|
||||
'default:',
|
||||
' admin_email: ""',
|
||||
' admin_password: ""'
|
||||
)
|
||||
|
||||
function Get-FreeTcpPort {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
@@ -160,28 +199,36 @@ $backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
|
||||
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
|
||||
|
||||
try {
|
||||
$serverSrcPath = Join-Path $projectRoot 'cmd\server'
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
$env:GOCACHE = $goCacheDir
|
||||
go build -o $serverExePath $serverSrcPath
|
||||
$env:GOMODCACHE = $goModCacheDir
|
||||
$env:GOPATH = $goPathDir
|
||||
$env:GOTELEMETRY = 'off'
|
||||
go build -o $serverExePath ./cmd/server
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'server build failed'
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOTELEMETRY -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$env:DATA_DIR = $e2eRunRoot
|
||||
$env:SERVER_PORT = "$selectedBackendPort"
|
||||
$env:DATABASE_DBNAME = $e2eDbPath
|
||||
$env:SERVER_MODE = 'debug'
|
||||
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
|
||||
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
|
||||
$env:SERVER_MODE = 'debug'
|
||||
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
|
||||
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
|
||||
$env:LOGGING_OUTPUT = 'stdout'
|
||||
$env:EMAIL_HOST = '127.0.0.1'
|
||||
$env:EMAIL_PORT = "$selectedSMTPPort"
|
||||
$env:EMAIL_FROM_EMAIL = 'noreply@test.local'
|
||||
$env:EMAIL_FROM_NAME = 'UMS E2E'
|
||||
$env:BOOTSTRAP_SECRET = $bootstrapSecret
|
||||
# JWT secret must be at least 32 bytes
|
||||
$env:JWT_SECRET = 'e2e-test-jwt-secret-at-least-32-bytes-long-for-security'
|
||||
|
||||
@@ -232,15 +279,25 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
$env:E2E_LOGIN_USERNAME = $AdminUsername
|
||||
$env:E2E_LOGIN_PASSWORD = $AdminPassword
|
||||
$env:E2E_LOGIN_EMAIL = $AdminEmail
|
||||
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
|
||||
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
|
||||
$env:E2E_EXTERNAL_WEB_SERVER = '1'
|
||||
$env:E2E_BASE_URL = $frontendBaseUrl
|
||||
$env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1"
|
||||
$env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile
|
||||
|
||||
Push-Location $frontendRoot
|
||||
try {
|
||||
$lastError = $null
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
$suiteAttempts = 2
|
||||
if ($env:E2E_SUITE_ATTEMPTS) {
|
||||
$parsedSuiteAttempts = 0
|
||||
if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) {
|
||||
$suiteAttempts = $parsedSuiteAttempts
|
||||
}
|
||||
}
|
||||
|
||||
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
|
||||
-Port $BrowserPort `
|
||||
@@ -249,7 +306,7 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
break
|
||||
} catch {
|
||||
$lastError = $_
|
||||
if ($attempt -ge 2) {
|
||||
if ($attempt -ge $suiteAttempts) {
|
||||
throw
|
||||
}
|
||||
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
|
||||
@@ -263,12 +320,15 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:DATA_DIR -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_API_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
|
||||
}
|
||||
} finally {
|
||||
@@ -290,9 +350,11 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
Remove-Item Env:EMAIL_FROM_NAME -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:DEFAULT_ADMIN_EMAIL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:DEFAULT_ADMIN_PASSWORD -ErrorAction SilentlyContinue
|
||||
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $e2eConfigPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const TEXT = {
|
||||
active: '\u542f\u7528',
|
||||
adminBootstrapTitle: '\u7cfb\u7edf\u5c1a\u672a\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
|
||||
adminRoleName: '\u7ba1\u7406\u5458',
|
||||
auditLogs: '\u5ba1\u8ba1\u65e5\u5fd7',
|
||||
adminBootstrapAction: '\u521d\u59cb\u5316\u7ba1\u7406\u5458',
|
||||
adminBootstrapPageTitle: '\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
|
||||
appTitle: '\u7528\u6237\u7ba1\u7406\u7cfb\u7edf',
|
||||
@@ -22,12 +23,13 @@ const TEXT = {
|
||||
bootstrapAdminConfirmPasswordPlaceholder: '\u786e\u8ba4\u7ba1\u7406\u5458\u5bc6\u7801',
|
||||
bootstrapAdminEmailPlaceholder: '\u7ba1\u7406\u5458\u90ae\u7bb1\uff08\u9009\u586b\uff09',
|
||||
bootstrapAdminPasswordPlaceholder: '\u7ba1\u7406\u5458\u5bc6\u7801',
|
||||
bootstrapAdminSecretPlaceholder: '\u5f15\u5bfc\u5bc6\u94a5',
|
||||
bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf',
|
||||
bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d',
|
||||
changePassword: '\u4fee\u6539\u5bc6\u7801',
|
||||
confirmPasswordPlaceholder: '\u786e\u8ba4\u5bc6\u7801',
|
||||
createAccount: '\u521b\u5efa\u8d26\u53f7',
|
||||
createUser: '\u521b\u5efa\u7528\u5458',
|
||||
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',
|
||||
@@ -45,6 +47,7 @@ const TEXT = {
|
||||
emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f',
|
||||
export: '\u5bfc\u51fa',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
integration: '\u96c6\u6210\u80fd\u529b',
|
||||
loginAction: '\u767b\u5f55',
|
||||
loginLogs: '\u767b\u5f55\u65e5\u5fd7',
|
||||
loginNow: '\u7acb\u5373\u767b\u5f55',
|
||||
@@ -70,7 +73,6 @@ const TEXT = {
|
||||
security: '\u5b89\u5168\u8bbe\u7f6e',
|
||||
smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801',
|
||||
status: '\u72b6\u6001',
|
||||
systemManagement: '\u7cfb\u7edf\u7ba1\u7406',
|
||||
todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55',
|
||||
totalUsers: '\u7528\u6237\u603b\u6570',
|
||||
trust: '\u4fe1\u4efb',
|
||||
@@ -81,7 +83,7 @@ const TEXT = {
|
||||
usernamePlaceholder: '\u7528\u6237\u540d',
|
||||
users: '\u7528\u6237\u7ba1\u7406',
|
||||
usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7',
|
||||
webhooks: 'Webhooks',
|
||||
webhooks: 'Webhook 管理',
|
||||
welcomeLogin: '\u6b22\u8fce\u767b\u5f55',
|
||||
}
|
||||
|
||||
@@ -101,6 +103,7 @@ const IGNORED_REQUEST_FAILURES = new Set([
|
||||
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 REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
|
||||
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
|
||||
|
||||
let managedCdpUrl = null
|
||||
@@ -213,6 +216,7 @@ function resolveCdpUrl() {
|
||||
|
||||
function createSignals() {
|
||||
return {
|
||||
rateLimitedResponses: [],
|
||||
consoleErrors: [],
|
||||
dialogs: [],
|
||||
pageErrors: [],
|
||||
@@ -472,6 +476,9 @@ function formatSignals(signals) {
|
||||
if (signals.requestFailures.length > 0) {
|
||||
lines.push(`request failures:\n${signals.requestFailures.join('\n')}`)
|
||||
}
|
||||
if (signals.rateLimitedResponses.length > 0) {
|
||||
lines.push(`rate-limited responses:\n${signals.rateLimitedResponses.join('\n')}`)
|
||||
}
|
||||
if (signals.unauthorizedResponses.length > 0) {
|
||||
lines.push(`unauthorized responses:\n${signals.unauthorizedResponses.join('\n')}`)
|
||||
}
|
||||
@@ -525,8 +532,23 @@ function attachSignalCollectors(page, signals) {
|
||||
}
|
||||
|
||||
const onResponse = (response) => {
|
||||
if (response.status() === 429) {
|
||||
signals.rateLimitedResponses.push(`${response.request().method()} ${response.url()}`)
|
||||
}
|
||||
|
||||
if (response.status() === 401) {
|
||||
signals.unauthorizedResponses.push(`${response.request().method()} ${response.url()}`)
|
||||
const authorization = response.request().headers().authorization
|
||||
const authState = authorization
|
||||
? `auth=present(${authorization.slice(0, 24)})`
|
||||
: 'auth=missing'
|
||||
const summary = `${response.request().method()} ${response.url()} :: ${authState}`
|
||||
signals.unauthorizedResponses.push(summary)
|
||||
void response.text().then((body) => {
|
||||
const compactBody = body.replace(/\s+/g, ' ').trim()
|
||||
if (compactBody) {
|
||||
signals.unauthorizedResponses.push(`${summary} :: ${compactBody}`)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +572,7 @@ function attachSignalCollectors(page, signals) {
|
||||
async function resetBrowserState(context, page) {
|
||||
logDebug('resetting browser state')
|
||||
await context.clearCookies()
|
||||
await page.setViewportSize({ width: VIEWPORTS[0].width, height: VIEWPORTS[0].height })
|
||||
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear()
|
||||
@@ -594,10 +617,31 @@ async function connectBrowserWithRetry() {
|
||||
throw lastError ?? new Error('Failed to connect to the Chromium CDP endpoint.')
|
||||
}
|
||||
|
||||
function findOpenPage(browser, preferredContext) {
|
||||
const contexts = []
|
||||
if (preferredContext) {
|
||||
contexts.push(preferredContext)
|
||||
}
|
||||
for (const candidateContext of browser.contexts()) {
|
||||
if (candidateContext !== preferredContext) {
|
||||
contexts.push(candidateContext)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidateContext of contexts) {
|
||||
const page = candidateContext.pages().find((candidate) => !candidate.isClosed())
|
||||
if (page) {
|
||||
return { context: candidateContext, page }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function ensurePersistentPage(browser, context) {
|
||||
let page = context.pages().find((candidate) => !candidate.isClosed())
|
||||
if (page) {
|
||||
return page
|
||||
let result = findOpenPage(browser, context)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -614,9 +658,9 @@ async function ensurePersistentPage(browser, context) {
|
||||
await openDevToolsPageTarget()
|
||||
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
page = context.pages().find((candidate) => !candidate.isClosed())
|
||||
if (page) {
|
||||
return page
|
||||
result = findOpenPage(browser, context)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
@@ -635,6 +679,10 @@ async function getProtectedRouteRedirect(page) {
|
||||
}
|
||||
|
||||
async function clickSidebarMenu(page, label) {
|
||||
await expect
|
||||
.poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const menuItems = page
|
||||
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
|
||||
.filter({ hasText: label })
|
||||
@@ -651,25 +699,88 @@ async function clickSidebarMenu(page, label) {
|
||||
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
|
||||
}
|
||||
async function openMobileNavigationIfNeeded(page) {
|
||||
const isMobileViewport = await page.evaluate(() => window.innerWidth < 768)
|
||||
if (!isMobileViewport) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(`No visible menu group found for ${label}.`)
|
||||
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
|
||||
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
|
||||
return false
|
||||
}
|
||||
|
||||
await forceClick(mobileMenuButton)
|
||||
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
|
||||
return true
|
||||
}
|
||||
|
||||
async function expandSidebarGroup(page, label) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page
|
||||
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
|
||||
.count()
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const findVisibleGroup = async () => {
|
||||
const groups = page
|
||||
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
|
||||
.filter({ hasText: label })
|
||||
|
||||
const count = await groups.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const group = groups.nth(index)
|
||||
if (await group.isVisible()) {
|
||||
return group
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let group = await findVisibleGroup()
|
||||
if (!group) {
|
||||
await openMobileNavigationIfNeeded(page)
|
||||
group = await findVisibleGroup()
|
||||
}
|
||||
|
||||
if (group) {
|
||||
await forceClick(group)
|
||||
return
|
||||
}
|
||||
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
|
||||
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
|
||||
.map((element) => (element.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
currentUrl: window.location.href,
|
||||
innerWidth: window.innerWidth,
|
||||
submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
|
||||
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
|
||||
}
|
||||
})
|
||||
|
||||
throw new Error(`No visible menu group found for ${label}. diagnostics=${JSON.stringify(diagnostics)}`)
|
||||
}
|
||||
|
||||
async function forceFillInput(locator, value) {
|
||||
await expect(locator).toBeVisible()
|
||||
try {
|
||||
await locator.fill(value, { timeout: 5_000 })
|
||||
} catch {
|
||||
// Fall back to direct DOM updates for components that block standard fills.
|
||||
}
|
||||
|
||||
const currentValue = await locator.inputValue().catch(() => null)
|
||||
if (currentValue === value) {
|
||||
return
|
||||
}
|
||||
|
||||
await locator.evaluate((element, nextValue) => {
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
throw new Error('Target element is not an input.')
|
||||
@@ -687,10 +798,18 @@ async function forceFillInput(locator, value) {
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, value)
|
||||
await expect(locator).toHaveValue(value)
|
||||
}
|
||||
|
||||
async function forceClick(locator) {
|
||||
await expect(locator).toBeVisible()
|
||||
try {
|
||||
await locator.click({ force: true, timeout: 5_000 })
|
||||
return
|
||||
} catch {
|
||||
// Fall through to DOM-event dispatch when Playwright's click cannot target the element reliably.
|
||||
}
|
||||
|
||||
await locator.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error('Target element is not clickable.')
|
||||
@@ -710,15 +829,17 @@ async function forceClick(locator) {
|
||||
}
|
||||
|
||||
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 await readCookie(page, REFRESH_TOKEN_COOKIE_NAME)
|
||||
}
|
||||
|
||||
return matched ? matched.slice(target.length) : null
|
||||
}, SESSION_PRESENCE_COOKIE_NAME)
|
||||
async function readSessionPresenceCookie(page) {
|
||||
return await readCookie(page, SESSION_PRESENCE_COOKIE_NAME)
|
||||
}
|
||||
|
||||
async function readCookie(page, cookieName) {
|
||||
const cookies = await page.context().cookies([BASE_URL])
|
||||
const matched = cookies.find((cookie) => cookie.name === cookieName)
|
||||
return matched?.value ?? null
|
||||
}
|
||||
|
||||
async function assertApiSuccessResponse(response, label) {
|
||||
@@ -744,26 +865,66 @@ async function assertApiSuccessResponse(response, label) {
|
||||
return payload
|
||||
}
|
||||
|
||||
function waitForResponseSafe(page, predicate, options) {
|
||||
return page.waitForResponse(predicate, options).then(
|
||||
(response) => ({ response }),
|
||||
(error) => ({ error }),
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveWaitForResponse(waitPromise) {
|
||||
const result = await waitPromise
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return result.response
|
||||
}
|
||||
|
||||
async function loginWithPassword(page, username, password, expectedUrlPattern) {
|
||||
const usernameInput = page
|
||||
.locator(`input[autocomplete="username"], input[placeholder="${TEXT.usernamePlaceholder}"]`)
|
||||
.first()
|
||||
const loginForm = usernameInput.locator('xpath=ancestor::form[1]')
|
||||
const passwordInput = loginForm.locator('input[type="password"]').first()
|
||||
const submitButton = loginForm.locator('button[type="submit"]').first()
|
||||
|
||||
await forceFillInput(usernameInput, username)
|
||||
await forceFillInput(loginForm.locator('input[type="password"]').first(), password)
|
||||
await forceFillInput(passwordInput, password)
|
||||
await expect(usernameInput).toHaveValue(username)
|
||||
await expect(passwordInput).toHaveValue(password)
|
||||
const loginResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
|
||||
}, { timeout: 5_000 }).catch(() => null)
|
||||
await forceClick(loginForm.locator('button[type="submit"]').first())
|
||||
try {
|
||||
await submitButton.click({ force: true, timeout: 5_000 })
|
||||
} catch {
|
||||
await forceClick(submitButton)
|
||||
}
|
||||
|
||||
const loginResponse = await loginResponsePromise
|
||||
let loginPayload = null
|
||||
if (loginResponse) {
|
||||
await assertApiSuccessResponse(loginResponse, 'password login')
|
||||
loginPayload = await assertApiSuccessResponse(loginResponse, 'password login')
|
||||
}
|
||||
|
||||
if (expectedUrlPattern) {
|
||||
await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 })
|
||||
try {
|
||||
await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 })
|
||||
} catch (error) {
|
||||
const pageText = await page.locator('body').innerText().catch(() => '')
|
||||
console.error('PASSWORD LOGIN DIAGNOSTICS', JSON.stringify({
|
||||
currentUrl: page.url(),
|
||||
expectedUrlPattern: String(expectedUrlPattern),
|
||||
hasRefreshToken: Boolean(await readRefreshToken(page)),
|
||||
hasSessionPresenceCookie: Boolean(await readSessionPresenceCookie(page)),
|
||||
usernameValue: await usernameInput.inputValue().catch(() => null),
|
||||
passwordValueLength: await passwordInput.inputValue().then((value) => value.length).catch(() => null),
|
||||
submitButtonDisabled: await submitButton.isDisabled().catch(() => null),
|
||||
loginPayload,
|
||||
pageText: pageText.slice(0, 2000),
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,6 +937,7 @@ async function loginFromLoginPage(page) {
|
||||
await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible()
|
||||
|
||||
await loginWithPassword(page, username, password, /\/dashboard$/)
|
||||
await expect(page.locator('.ant-layout-header')).toBeVisible({ timeout: 20 * 1000 })
|
||||
|
||||
return { username, password }
|
||||
}
|
||||
@@ -784,12 +946,15 @@ async function verifyAdminBootstrapWorkflow(page) {
|
||||
const username = requireEnv('E2E_LOGIN_USERNAME')
|
||||
const password = requireEnv('E2E_LOGIN_PASSWORD')
|
||||
const email = (process.env.E2E_LOGIN_EMAIL ?? `${username}@example.com`).trim()
|
||||
const bootstrapSecret = requireEnv('E2E_BOOTSTRAP_SECRET')
|
||||
const apiBaseUrl = requireEnv('E2E_API_BASE_URL')
|
||||
|
||||
const capabilitiesResponse = page.waitForResponse((response) => {
|
||||
const capabilitiesResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
|
||||
})
|
||||
|
||||
await page.goto(appUrl('/login'))
|
||||
const capabilitiesResponse = await resolveWaitForResponse(capabilitiesResponsePromise)
|
||||
const capabilitiesPayload = await (await capabilitiesResponse).json()
|
||||
expect(Boolean(capabilitiesPayload?.data?.admin_bootstrap_required)).toBe(true)
|
||||
|
||||
@@ -800,16 +965,26 @@ async function verifyAdminBootstrapWorkflow(page) {
|
||||
|
||||
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminUsernamePlaceholder}"]`).first(), username)
|
||||
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminEmailPlaceholder}"]`).first(), email)
|
||||
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminSecretPlaceholder}"]`).first(), bootstrapSecret)
|
||||
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminPasswordPlaceholder}"]`).first(), password)
|
||||
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminConfirmPasswordPlaceholder}"]`).first(), password)
|
||||
|
||||
const [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 })),
|
||||
])
|
||||
const bootstrapResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/auth/bootstrap-admin') && response.request().method() === 'POST'
|
||||
})
|
||||
await forceClick(page.getByRole('button', { name: TEXT.bootstrapAdminSubmit }))
|
||||
const bootstrapResponse = await resolveWaitForResponse(bootstrapResponsePromise)
|
||||
await assertApiSuccessResponse(bootstrapResponse, 'bootstrap admin')
|
||||
const bootstrapPayload = await bootstrapResponse.json()
|
||||
expect(Boolean(bootstrapPayload?.data?.access_token)).toBe(true)
|
||||
expect(Boolean(bootstrapPayload?.data?.user?.id)).toBe(true)
|
||||
const backendTokenCheck = await fetch(`${apiBaseUrl}/auth/userinfo`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${bootstrapPayload.data.access_token}`,
|
||||
},
|
||||
})
|
||||
const backendTokenCheckBody = await backendTokenCheck.text()
|
||||
expect(backendTokenCheck.status, backendTokenCheckBody).toBe(200)
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30 * 1000 })
|
||||
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
|
||||
@@ -836,12 +1011,11 @@ async function verifyPublicRegistration(page) {
|
||||
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 })),
|
||||
])
|
||||
const registerResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
|
||||
})
|
||||
await forceClick(page.getByRole('button', { name: TEXT.createAccount }))
|
||||
const registerResponse = await resolveWaitForResponse(registerResponsePromise)
|
||||
await assertApiSuccessResponse(registerResponse, 'register')
|
||||
|
||||
await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 })
|
||||
@@ -873,22 +1047,20 @@ async function verifyEmailActivationWorkflow(page) {
|
||||
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 })),
|
||||
])
|
||||
const registerResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
|
||||
})
|
||||
await forceClick(page.getByRole('button', { name: TEXT.createAccount }))
|
||||
const registerResponse = await resolveWaitForResponse(registerResponsePromise)
|
||||
await assertApiSuccessResponse(registerResponse, 'register email activation')
|
||||
await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 })
|
||||
|
||||
const activationLink = await waitForActivationLink(email)
|
||||
const [activationResponse] = await Promise.all([
|
||||
page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/auth/activate') && response.request().method() === 'GET'
|
||||
}),
|
||||
page.goto(activationLink),
|
||||
])
|
||||
const activationResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/auth/activate-email') && response.request().method() === 'POST'
|
||||
})
|
||||
await page.goto(activationLink)
|
||||
const activationResponse = await resolveWaitForResponse(activationResponsePromise)
|
||||
await assertApiSuccessResponse(activationResponse, 'activate email')
|
||||
await expect(page.locator('body')).toContainText(TEXT.emailActivationSuccess, { timeout: 20 * 1000 })
|
||||
await forceClick(page.getByRole('button', { name: TEXT.loginNow }))
|
||||
@@ -907,11 +1079,13 @@ async function runScenario(browser, context, name, fn) {
|
||||
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) {
|
||||
const requestedContext = browser.contexts()[0] ?? context
|
||||
const resolvedPage = await ensurePersistentPage(browser, requestedContext)
|
||||
if (!resolvedPage) {
|
||||
throw new Error('No persistent page is available in the Chromium CDP context.')
|
||||
}
|
||||
const activeContext = resolvedPage.context
|
||||
const page = resolvedPage.page
|
||||
|
||||
for (const extraPage of activeContext.pages()) {
|
||||
if (extraPage === page) {
|
||||
@@ -958,14 +1132,15 @@ async function runScenario(browser, context, name, fn) {
|
||||
|
||||
async function verifyLoginSurface(page) {
|
||||
console.log('STEP login-surface wait-capabilities')
|
||||
const capabilitiesResponse = page.waitForResponse((response) => {
|
||||
const capabilitiesResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
|
||||
})
|
||||
|
||||
console.log('STEP login-surface goto-login')
|
||||
await page.goto(appUrl('/login'))
|
||||
console.log('STEP login-surface capabilities-response')
|
||||
const capabilitiesPayload = await (await capabilitiesResponse).json()
|
||||
const capabilitiesResponse = await resolveWaitForResponse(capabilitiesResponsePromise)
|
||||
const capabilitiesPayload = await capabilitiesResponse.json()
|
||||
const capabilities = capabilitiesPayload?.data ?? {}
|
||||
|
||||
await expect(page).toHaveTitle(new RegExp(TEXT.appTitle))
|
||||
@@ -1036,7 +1211,7 @@ async function verifyAuthWorkflow(page) {
|
||||
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) => {
|
||||
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
|
||||
})
|
||||
await forceFillInput(
|
||||
@@ -1052,7 +1227,7 @@ async function verifyAuthWorkflow(page) {
|
||||
`${createdUsername}@example.com`,
|
||||
)
|
||||
await forceClick(createUserModal.locator('.ant-btn-primary').last())
|
||||
const createUserResponse = await createUserResponsePromise
|
||||
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
|
||||
await assertApiSuccessResponse(createUserResponse, 'create user')
|
||||
await expect(createUserModal).toHaveClass(/ant-zoom-leave/, { timeout: 20 * 1000 })
|
||||
await page.goto(appUrl('/users'))
|
||||
@@ -1062,7 +1237,18 @@ async function verifyAuthWorkflow(page) {
|
||||
|
||||
await page.goto(appUrl('/roles'))
|
||||
await expect(page).toHaveURL(/\/roles$/)
|
||||
await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible()
|
||||
try {
|
||||
await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible()
|
||||
} catch (error) {
|
||||
const pageText = await page.locator('body').innerText().catch(() => '')
|
||||
console.error('ROLES PAGE DIAGNOSTICS', JSON.stringify({
|
||||
currentUrl: page.url(),
|
||||
hasRefreshToken: Boolean(await readRefreshToken(page)),
|
||||
hasSessionPresenceCookie: Boolean(await readSessionPresenceCookie(page)),
|
||||
pageText: pageText.slice(0, 2000),
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible()
|
||||
|
||||
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
|
||||
@@ -1168,7 +1354,7 @@ async function verifyUserManagementCRUD(page) {
|
||||
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
|
||||
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
const createUserResponsePromise = page.waitForResponse((response) => {
|
||||
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
|
||||
})
|
||||
await forceFillInput(
|
||||
@@ -1184,40 +1370,41 @@ async function verifyUserManagementCRUD(page) {
|
||||
testEmail,
|
||||
)
|
||||
await forceClick(createUserModal.locator('.ant-btn-primary').last())
|
||||
const createUserResponse = await createUserResponsePromise
|
||||
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
|
||||
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
|
||||
|
||||
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
|
||||
|
||||
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
|
||||
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
|
||||
const editDrawer = page.locator('.ant-drawer')
|
||||
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
|
||||
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
const editResponsePromise = page.waitForResponse((response) => {
|
||||
const editResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
|
||||
})
|
||||
await forceClick(editDrawer.locator('.ant-btn-primary').last())
|
||||
const editResponse = await editResponsePromise
|
||||
const editResponse = await resolveWaitForResponse(editResponsePromise)
|
||||
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
|
||||
|
||||
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
|
||||
const detailDrawer = page.locator('.ant-drawer')
|
||||
const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
|
||||
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(detailDrawer).toContainText(testUsername)
|
||||
|
||||
await page.goto(appUrl('/users'))
|
||||
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
|
||||
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 10 * 1000 })
|
||||
const filteredUserRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
|
||||
await expect(filteredUserRow).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(userRow.getByRole('button', { name: TEXT.delete }))
|
||||
const deleteConfirmModal = page.locator('.ant-modal-confirm')
|
||||
await expect(deleteConfirmModal).toBeVisible({ timeout: 10 * 1000 })
|
||||
const deleteResponsePromise = page.waitForResponse((response) => {
|
||||
await forceClick(filteredUserRow.getByRole('button', { name: TEXT.delete }))
|
||||
const deleteConfirmPopover = page.locator('.ant-popconfirm').filter({ hasText: testUsername }).last()
|
||||
await expect(deleteConfirmPopover).toBeVisible({ timeout: 10 * 1000 })
|
||||
const deleteResponsePromise = waitForResponseSafe(page, (response) => {
|
||||
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'DELETE'
|
||||
})
|
||||
await forceClick(deleteConfirmModal.locator('.ant-btn-primary').last())
|
||||
const deleteResponse = await deleteResponsePromise
|
||||
await forceClick(deleteConfirmPopover.locator('.ant-btn-primary').last())
|
||||
const deleteResponse = await resolveWaitForResponse(deleteResponsePromise)
|
||||
await assertApiSuccessResponse(deleteResponse, 'delete user CRUD')
|
||||
|
||||
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toHaveCount(0, { timeout: 10 * 1000 })
|
||||
@@ -1240,11 +1427,10 @@ async function verifyRoleManagementCRUD(page) {
|
||||
|
||||
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
|
||||
await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction }))
|
||||
const permissionsModal = page.locator('.ant-modal')
|
||||
const permissionsModal = page.getByRole('dialog').filter({ hasText: TEXT.assignPermissions }).last()
|
||||
await expect(permissionsModal.locator('.ant-modal-title')).toContainText(TEXT.assignPermissions)
|
||||
|
||||
await forceClick(permissionsModal.locator('.ant-modal-close'))
|
||||
await expect(permissionsModal).not.toBeVisible({ timeout: 10 * 1000 })
|
||||
await page.goto(appUrl('/roles'))
|
||||
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
@@ -1255,11 +1441,10 @@ async function verifyDeviceManagement(page) {
|
||||
logDebug('verifyDeviceManagement: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.devices)
|
||||
await page.goto(appUrl('/devices'))
|
||||
await expect(page).toHaveURL(/\/devices$/)
|
||||
|
||||
await expect(page.getByText(TEXT.deviceManagement)).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(page.getByRole('heading', { name: TEXT.deviceManagement })).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
@@ -1270,11 +1455,10 @@ async function verifyLoginLogs(page) {
|
||||
logDebug('verifyLoginLogs: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.loginLogs)
|
||||
await expect(page).toHaveURL(/\/login-logs$/)
|
||||
await page.goto(appUrl('/logs/login'))
|
||||
await expect(page).toHaveURL(/\/logs\/login$/)
|
||||
|
||||
await expect(page.getByText(TEXT.loginLogs)).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(page.getByRole('heading', { name: TEXT.loginLogs })).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
@@ -1285,11 +1469,10 @@ async function verifyOperationLogs(page) {
|
||||
logDebug('verifyOperationLogs: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.operationLogs)
|
||||
await expect(page).toHaveURL(/\/operation-logs$/)
|
||||
await page.goto(appUrl('/logs/operation'))
|
||||
await expect(page).toHaveURL(/\/logs\/operation$/)
|
||||
|
||||
await expect(page.getByText(TEXT.operationLogs)).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(page.getByRole('heading', { name: TEXT.operationLogs })).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
@@ -1300,11 +1483,10 @@ async function verifyWebhookManagement(page) {
|
||||
logDebug('verifyWebhookManagement: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.webhooks)
|
||||
await page.goto(appUrl('/webhooks'))
|
||||
await expect(page).toHaveURL(/\/webhooks$/)
|
||||
|
||||
await expect(page.getByText(TEXT.webhooks)).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(page.getByRole('heading', { name: TEXT.webhooks })).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
@@ -1322,10 +1504,10 @@ async function verifyProfileAndSecurity(page) {
|
||||
await expect(page.locator('body')).toContainText(credentials.username, { timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.security))
|
||||
await forceClick(page.getByRole('menuitem', { name: TEXT.security }))
|
||||
await expect(page).toHaveURL(/\/profile\/security$/)
|
||||
|
||||
await expect(page.getByText(TEXT.changePassword)).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
@@ -1349,6 +1531,12 @@ async function main() {
|
||||
let browser = null
|
||||
let managedBrowser = null
|
||||
let managedProfileDir = null
|
||||
const selectedScenarioNames = new Set(
|
||||
(process.env.E2E_SCENARIOS ?? '')
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
if (process.env.E2E_MANAGED_BROWSER === '1') {
|
||||
const browserPath = await resolveManagedBrowserPath()
|
||||
@@ -1370,23 +1558,39 @@ async function main() {
|
||||
throw new Error('No persistent Chromium context is available through CDP.')
|
||||
}
|
||||
|
||||
const scenarios = []
|
||||
if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') {
|
||||
await runScenario(browser, context, 'admin-bootstrap', verifyAdminBootstrapWorkflow)
|
||||
scenarios.push(['admin-bootstrap', verifyAdminBootstrapWorkflow])
|
||||
}
|
||||
scenarios.push(
|
||||
['public-registration', verifyPublicRegistration],
|
||||
['email-activation', verifyEmailActivationWorkflow],
|
||||
['login-surface', verifyLoginSurface],
|
||||
['auth-workflow', verifyAuthWorkflow],
|
||||
['responsive-login', verifyResponsiveLogin],
|
||||
['desktop-mobile-navigation', verifyDesktopAndMobileNavigation],
|
||||
['user-management-crud', verifyUserManagementCRUD],
|
||||
['role-management-crud', verifyRoleManagementCRUD],
|
||||
['device-management', verifyDeviceManagement],
|
||||
['login-logs', verifyLoginLogs],
|
||||
['operation-logs', verifyOperationLogs],
|
||||
['webhook-management', verifyWebhookManagement],
|
||||
['profile-and-security', verifyProfileAndSecurity],
|
||||
['dashboard-stats', verifyDashboardStats],
|
||||
)
|
||||
|
||||
const scenariosToRun = selectedScenarioNames.size === 0
|
||||
? scenarios
|
||||
: scenarios.filter(([name]) => name === 'admin-bootstrap' || selectedScenarioNames.has(name))
|
||||
|
||||
if (scenariosToRun.length === 0) {
|
||||
throw new Error(`No E2E scenarios matched E2E_SCENARIOS=${process.env.E2E_SCENARIOS ?? ''}`)
|
||||
}
|
||||
|
||||
console.log(`SCENARIOS ${scenariosToRun.map(([name]) => name).join(', ')}`)
|
||||
for (const [name, fn] of scenariosToRun) {
|
||||
await runScenario(browser, context, name, fn)
|
||||
}
|
||||
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)
|
||||
await runScenario(browser, context, 'user-management-crud', verifyUserManagementCRUD)
|
||||
await runScenario(browser, context, 'role-management-crud', verifyRoleManagementCRUD)
|
||||
await runScenario(browser, context, 'device-management', verifyDeviceManagement)
|
||||
await runScenario(browser, context, 'login-logs', verifyLoginLogs)
|
||||
await runScenario(browser, context, 'operation-logs', verifyOperationLogs)
|
||||
await runScenario(browser, context, 'webhook-management', verifyWebhookManagement)
|
||||
await runScenario(browser, context, 'profile-and-security', verifyProfileAndSecurity)
|
||||
await runScenario(browser, context, 'dashboard-stats', verifyDashboardStats)
|
||||
console.log('Playwright CDP E2E completed successfully')
|
||||
} finally {
|
||||
await browser?.close().catch(() => {})
|
||||
|
||||
Reference in New Issue
Block a user