fix(n+1): 批量查询替代循环单查

- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量
- AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量
- 在 userRepositoryInterface 补充 GetByIDs 方法签名
This commit is contained in:
2026-05-08 08:05:26 +08:00
parent 9b1cea246e
commit 2a18a6fb47
39 changed files with 3169 additions and 393 deletions

View File

@@ -0,0 +1,43 @@
export const BASE_SCENARIO_NAMES = [
'public-registration',
'email-activation',
'password-reset',
'login-surface',
'auth-workflow',
'responsive-login',
'desktop-mobile-navigation',
'user-management-crud',
'user-management-batch',
'role-management-crud',
'permissions-management-crud',
'device-management',
'login-logs',
'operation-logs',
'webhook-management',
'import-export',
'profile-management',
'profile-and-security',
'settings',
'dashboard-stats',
]
export function parseSelectedScenarioNames(rawScenarioNames = '') {
return new Set(
String(rawScenarioNames ?? '')
.split(',')
.map((name) => name.trim())
.filter(Boolean),
)
}
export function selectScenarioNames({ requestedScenarioNames, expectAdminBootstrap }) {
const scenarioNames = expectAdminBootstrap
? ['admin-bootstrap', ...BASE_SCENARIO_NAMES]
: [...BASE_SCENARIO_NAMES]
if (!requestedScenarioNames || requestedScenarioNames.size === 0) {
return scenarioNames
}
return scenarioNames.filter((name) => name === 'admin-bootstrap' || requestedScenarioNames.has(name))
}

View File

@@ -1,4 +1,4 @@
import { mkdtemp, readdir, rm, access, mkdir } from 'node:fs/promises'
import { mkdtemp, readdir, rm, access } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
@@ -76,7 +76,7 @@ async function main() {
} else {
browserPath = await resolveBrowserPath()
port = await getFreePort()
profileDir = await createBrowserProfileDir(browserPath, port)
profileDir = await createBrowserProfileDir()
browser = startBrowser(browserPath, port, profileDir)
cdpBaseUrl = `http://127.0.0.1:${port}`
}
@@ -150,7 +150,15 @@ function resolveExternalCdpBaseUrl() {
}
function startBrowser(browserPath, port, profileDir) {
const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox']
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--noerrdialogs',
'--no-sandbox',
'--disable-breakpad',
'--disable-crash-reporter',
'--disable-crashpad-for-testing',
]
if (isHeadlessShell(browserPath)) {
args.push('--single-process')
@@ -181,14 +189,8 @@ function startBrowser(browserPath, port, profileDir) {
return browser
}
async function createBrowserProfileDir(browserPath, port) {
if (!isHeadlessShell(browserPath)) {
return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-'))
}
const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles')
await mkdir(profileRoot, { recursive: true })
return path.join(profileRoot, `pw-profile-cdp-smoke-node-${port}`)
async function createBrowserProfileDir() {
return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-'))
}
async function resolveBrowserPath() {

View File

@@ -104,11 +104,15 @@ function Get-BrowserArguments {
$arguments = @(
"--remote-debugging-port=$Port",
"--user-data-dir=$ProfileDir",
'--noerrdialogs',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-breakpad',
'--disable-crash-reporter',
'--disable-crashpad-for-testing',
'--disable-sync',
'--disable-gpu'
)
@@ -337,7 +341,7 @@ function Remove-BrowserLogs {
$browserPath = Resolve-BrowserPath
Write-Host "CDP browser: $browserPath"
$Port = if ($Port -gt 0) { $Port } else { Get-FreeTcpPort }
$profileRoot = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path '.cache\cdp-profiles'
$profileRoot = Join-Path $env:TEMP 'ums-cdp-profiles'
New-Item -ItemType Directory -Force $profileRoot | Out-Null
$profileDir = Join-Path $profileRoot "pw-profile-cdp-smoke-win-$Port"
$browserReadyUrl = "http://127.0.0.1:$Port/json/version"

View File

@@ -125,6 +125,75 @@ function Sync-AdminBootstrapExpectation {
Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap"
}
function Get-PositiveIntegerFromEnv {
param(
[Parameter(Mandatory = $true)][string]$Name,
[int]$DefaultValue = 3
)
$rawValue = [Environment]::GetEnvironmentVariable($Name)
if ([string]::IsNullOrWhiteSpace($rawValue)) {
return $DefaultValue
}
$parsedValue = 0
if ([int]::TryParse($rawValue, [ref]$parsedValue) -and $parsedValue -gt 0) {
return $parsedValue
}
return $DefaultValue
}
function Get-PlaywrightScenarioNames {
$env:E2E_LIST_SCENARIOS = '1'
try {
$output = & node ./scripts/run-playwright-cdp-e2e.mjs
if ($LASTEXITCODE -ne 0) {
throw "failed to list Playwright CDP scenarios with exit code $LASTEXITCODE"
}
return @($output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
} finally {
Remove-Item Env:E2E_LIST_SCENARIOS -ErrorAction SilentlyContinue
}
}
function Invoke-IsolatedPlaywrightScenario {
param(
[Parameter(Mandatory = $true)][string]$ScenarioName,
[Parameter(Mandatory = $true)][string]$BackendBaseUrl,
[int]$BrowserPort = 0,
[int]$ScenarioAttempts = 3
)
$lastError = $null
for ($attempt = 1; $attempt -le $ScenarioAttempts; $attempt++) {
try {
Sync-AdminBootstrapExpectation -BackendBaseUrl $BackendBaseUrl
$env:E2E_SCENARIOS = $ScenarioName
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge $ScenarioAttempts) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp scenario retry [$ScenarioName]: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
} finally {
Remove-Item Env:E2E_SCENARIOS -ErrorAction SilentlyContinue
}
}
if ($lastError) {
throw $lastError
}
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
@@ -309,36 +378,53 @@ try {
Push-Location $frontendRoot
try {
$lastError = $null
$suiteAttempts = 3
if ($env:E2E_SUITE_ATTEMPTS) {
$parsedSuiteAttempts = 0
if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) {
$suiteAttempts = $parsedSuiteAttempts
}
$scenarioIsolationEnabled = $true
if ($env:E2E_SCENARIO_ISOLATION -eq '0') {
$scenarioIsolationEnabled = $false
}
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
try {
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge $suiteAttempts) {
throw
$suiteAttempts = Get-PositiveIntegerFromEnv -Name 'E2E_SUITE_ATTEMPTS' -DefaultValue 3
$scenarioAttempts = Get-PositiveIntegerFromEnv -Name 'E2E_SCENARIO_ATTEMPTS' -DefaultValue $suiteAttempts
if ($scenarioIsolationEnabled) {
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
$scenarioNames = Get-PlaywrightScenarioNames
if ($scenarioNames.Count -eq 0) {
throw 'no Playwright CDP scenarios were selected for execution'
}
Write-Host "playwright-cdp isolated scenarios: $($scenarioNames -join ', ')"
foreach ($scenarioName in $scenarioNames) {
Invoke-IsolatedPlaywrightScenario `
-ScenarioName $scenarioName `
-BackendBaseUrl $backendBaseUrl `
-BrowserPort $BrowserPort `
-ScenarioAttempts $scenarioAttempts
}
} else {
$lastError = $null
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
try {
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge $suiteAttempts) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
}
}
if ($lastError) {
throw $lastError
if ($lastError) {
throw $lastError
}
}
} finally {
Pop-Location
@@ -351,6 +437,8 @@ try {
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_LIST_SCENARIOS -ErrorAction SilentlyContinue
Remove-Item Env:E2E_SCENARIOS -ErrorAction SilentlyContinue
Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
}
} finally {

View File

@@ -1,5 +1,5 @@
import process from 'node:process'
import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { spawn } from 'node:child_process'
import { createHmac } from 'node:crypto'
@@ -8,6 +8,8 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { chromium, expect } from '@playwright/test'
import { parseSelectedScenarioNames, selectScenarioNames } from './playwright-e2e-scenarios.mjs'
const TEXT = {
accessControl: '\u8bbf\u95ee\u63a7\u5236',
active: '\u542f\u7528',
@@ -84,6 +86,10 @@ const TEXT = {
permissionsAction: '\u6743\u9650',
permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650',
profile: '\u4e2a\u4eba\u8d44\u6599',
profileBioPlaceholder: '\u4ecb\u7ecd\u4e00\u4e0b\u81ea\u5df1...',
profileNicknamePlaceholder: '\u8bf7\u8f93\u5165\u6635\u79f0',
profileRegionPlaceholder: '\u8bf7\u8f93\u5165\u5730\u533a',
profileSaveChanges: '\u4fdd\u5b58\u4fee\u6539',
profileConfirmPasswordPlaceholder: '\u8bf7\u518d\u6b21\u8f93\u5165\u65b0\u5bc6\u7801',
registerEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740\uff08\u9009\u586b\uff09',
registerSuccess: '\u6ce8\u518c\u6210\u529f',
@@ -426,25 +432,23 @@ async function resolveManagedBrowserPath() {
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}`)
async function createManagedBrowserProfileDir() {
return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-'))
}
function startManagedBrowser(browserPath, port, profileDir) {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--noerrdialogs',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-breakpad',
'--disable-crash-reporter',
'--disable-crashpad-for-testing',
'--disable-sync',
'--disable-gpu',
]
@@ -951,8 +955,8 @@ async function forceFillInput(locator, value) {
}
await locator.evaluate((element, nextValue) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error('Target element is not an input.')
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
throw new Error('Target element is not a text input.')
}
element.focus()
@@ -2317,6 +2321,62 @@ async function verifySettings(page) {
await expect(page).toHaveURL(/\/login$/)
}
async function verifyProfileManagement(page) {
logDebug('verifyProfileManagement: admin login /login')
await loginFromLoginPage(page)
const profileUsername = `e2e_profile_${Date.now()}`
const profilePassword = 'Profile123!@#'
const profileLandingPattern = /\/profile$/
const suffix = Date.now()
const updatedNickname = `Profile User ${suffix}`
const updatedRegion = `Hangzhou-${suffix}`
logDebug('verifyProfileManagement: goto /users as admin')
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const createdUser = await createUserFromUsersPage(page, profileUsername, profilePassword)
logDebug(`verifyProfileManagement: created user ${createdUser.username}`)
logDebug('verifyProfileManagement: reset session before profile user login')
await resetSessionToLogin(page)
logDebug(`verifyProfileManagement: profile user login ${createdUser.username}`)
await loginWithPassword(page, createdUser.username, profilePassword, profileLandingPattern)
await installFetchDiagnostics(page)
await expect(page).toHaveURL(/\/profile$/)
await expect(page.getByRole('heading', { name: TEXT.profile })).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('body')).toContainText(createdUser.username, { timeout: 10 * 1000 })
await expect(page.locator('body')).toContainText(createdUser.email)
await forceFillInput(page.getByPlaceholder(TEXT.profileNicknamePlaceholder).first(), updatedNickname)
await forceFillInput(page.getByPlaceholder(TEXT.profileRegionPlaceholder).first(), updatedRegion)
await forceFillInput(page.getByPlaceholder(TEXT.profileBioPlaceholder).first(), `Profile bio ${suffix}`)
const updateProfileFetchCount = await getFetchDiagnosticsCount(page)
const updateProfileFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/users\/\d+$/) && entry.method === 'PUT'
}, async () => {
await forceClick(page.getByRole('button', { name: TEXT.profileSaveChanges }).first())
}, {
afterCount: updateProfileFetchCount,
label: 'update profile fetch',
})
assertFetchLogSuccess(updateProfileFetch, 'update profile')
await expect(page.getByPlaceholder(TEXT.profileNicknamePlaceholder).first()).toHaveValue(updatedNickname, { timeout: 20 * 1000 })
await expect(page.getByPlaceholder(TEXT.profileRegionPlaceholder).first()).toHaveValue(updatedRegion, { timeout: 20 * 1000 })
await expect(page.getByPlaceholder(TEXT.profileBioPlaceholder).first()).toHaveValue(`Profile bio ${suffix}`)
await forceClick(page.locator('a[href="/profile/security"]').first())
await expect(page).toHaveURL(/\/profile\/security$/)
await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 })
await resetSessionToLogin(page)
logDebug('verifyProfileManagement: completed')
}
async function verifyProfileAndSecurity(page) {
logDebug('verifyProfileAndSecurity: admin login /login')
await loginFromLoginPage(page)
@@ -2436,17 +2496,44 @@ async function main() {
let runtime = null
let managedBrowser = null
let managedProfileDir = null
const selectedScenarioNames = new Set(
(process.env.E2E_SCENARIOS ?? '')
.split(',')
.map((name) => name.trim())
.filter(Boolean),
)
const selectedScenarioNames = parseSelectedScenarioNames(process.env.E2E_SCENARIOS ?? '')
const scenarioEntries = new Map([
['admin-bootstrap', verifyAdminBootstrapWorkflow],
['public-registration', verifyPublicRegistration],
['email-activation', verifyEmailActivationWorkflow],
['password-reset', verifyPasswordResetWorkflow],
['login-surface', verifyLoginSurface],
['auth-workflow', verifyAuthWorkflow],
['responsive-login', verifyResponsiveLogin],
['desktop-mobile-navigation', verifyDesktopAndMobileNavigation],
['user-management-crud', verifyUserManagementCRUD],
['user-management-batch', verifyUserManagementBatch],
['role-management-crud', verifyRoleManagementCRUD],
['permissions-management-crud', verifyPermissionsManagementCRUD],
['device-management', verifyDeviceManagement],
['login-logs', verifyLoginLogs],
['operation-logs', verifyOperationLogs],
['webhook-management', verifyWebhookManagement],
['import-export', verifyImportExport],
['profile-management', verifyProfileManagement],
['profile-and-security', verifyProfileAndSecurity],
['settings', verifySettings],
['dashboard-stats', verifyDashboardStats],
])
const scenarioNamesToRun = selectScenarioNames({
requestedScenarioNames: selectedScenarioNames,
expectAdminBootstrap: process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1',
})
if (process.env.E2E_LIST_SCENARIOS === '1') {
console.log(scenarioNamesToRun.join('\n'))
return
}
if (process.env.E2E_MANAGED_BROWSER === '1') {
const browserPath = await resolveManagedBrowserPath()
const port = await getFreePort()
managedProfileDir = await createManagedBrowserProfileDir(browserPath, port)
managedProfileDir = await createManagedBrowserProfileDir()
managedBrowser = startManagedBrowser(browserPath, port, managedProfileDir)
managedCdpUrl = `http://127.0.0.1:${port}`
console.log(`LAUNCH playwright-cdp ${browserPath}`)
@@ -2466,35 +2553,13 @@ async function main() {
throw new Error('No persistent Chromium context is available through CDP.')
}
const scenarios = []
if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') {
scenarios.push(['admin-bootstrap', verifyAdminBootstrapWorkflow])
}
scenarios.push(
['public-registration', verifyPublicRegistration],
['email-activation', verifyEmailActivationWorkflow],
['password-reset', verifyPasswordResetWorkflow],
['login-surface', verifyLoginSurface],
['auth-workflow', verifyAuthWorkflow],
['responsive-login', verifyResponsiveLogin],
['desktop-mobile-navigation', verifyDesktopAndMobileNavigation],
['user-management-crud', verifyUserManagementCRUD],
['user-management-batch', verifyUserManagementBatch],
['role-management-crud', verifyRoleManagementCRUD],
['permissions-management-crud', verifyPermissionsManagementCRUD],
['device-management', verifyDeviceManagement],
['login-logs', verifyLoginLogs],
['operation-logs', verifyOperationLogs],
['webhook-management', verifyWebhookManagement],
['import-export', verifyImportExport],
['profile-and-security', verifyProfileAndSecurity],
['settings', verifySettings],
['dashboard-stats', verifyDashboardStats],
)
const scenariosToRun = selectedScenarioNames.size === 0
? scenarios
: scenarios.filter(([name]) => name === 'admin-bootstrap' || selectedScenarioNames.has(name))
const scenariosToRun = scenarioNamesToRun.map((name) => {
const handler = scenarioEntries.get(name)
if (!handler) {
throw new Error(`No Playwright CDP scenario handler is registered for ${name}.`)
}
return [name, handler]
})
if (scenariosToRun.length === 0) {
throw new Error(`No E2E scenarios matched E2E_SCENARIOS=${process.env.E2E_SCENARIOS ?? ''}`)