feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
import process from 'node:process'
import path from 'node:path'
import net from 'node:net'
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
function parseArgs(argv) {
const args = new Map()
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (!value.startsWith('--')) {
continue
}
const key = value.slice(2)
const nextValue = argv[index + 1]
if (nextValue && !nextValue.startsWith('--')) {
args.set(key, nextValue)
index += 1
continue
}
args.set(key, 'true')
}
return args
}
const args = parseArgs(process.argv.slice(2))
const port = Number(args.get('port') ?? process.env.SMTP_CAPTURE_PORT ?? 2525)
const outputPath = path.resolve(args.get('output') ?? process.env.SMTP_CAPTURE_OUTPUT ?? './smtp-capture.jsonl')
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid SMTP capture port: ${port}`)
}
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, '', 'utf8')
let writeQueue = Promise.resolve()
function queueMessageWrite(message) {
writeQueue = writeQueue.then(() => appendFile(outputPath, `${JSON.stringify(message)}\n`, 'utf8'))
return writeQueue
}
function createSessionState() {
return {
buffer: '',
dataMode: false,
mailFrom: '',
rcptTo: [],
data: '',
}
}
const server = net.createServer((socket) => {
socket.setEncoding('utf8')
let session = createSessionState()
const reply = (line) => {
socket.write(`${line}\r\n`)
}
const resetMessageState = () => {
session.dataMode = false
session.mailFrom = ''
session.rcptTo = []
session.data = ''
}
const flushBuffer = async () => {
while (true) {
if (session.dataMode) {
const messageTerminatorIndex = session.buffer.indexOf('\r\n.\r\n')
if (messageTerminatorIndex === -1) {
session.data += session.buffer
session.buffer = ''
return
}
session.data += session.buffer.slice(0, messageTerminatorIndex)
session.buffer = session.buffer.slice(messageTerminatorIndex + 5)
const capturedMessage = {
timestamp: new Date().toISOString(),
mailFrom: session.mailFrom,
rcptTo: session.rcptTo,
data: session.data.replace(/\r\n\.\./g, '\r\n.'),
}
await queueMessageWrite(capturedMessage)
resetMessageState()
reply('250 OK')
continue
}
const lineEndIndex = session.buffer.indexOf('\r\n')
if (lineEndIndex === -1) {
return
}
const line = session.buffer.slice(0, lineEndIndex)
session.buffer = session.buffer.slice(lineEndIndex + 2)
const normalized = line.toUpperCase()
if (normalized.startsWith('EHLO')) {
socket.write('250-localhost\r\n250 OK\r\n')
continue
}
if (normalized.startsWith('HELO')) {
reply('250 OK')
continue
}
if (normalized.startsWith('MAIL FROM:')) {
resetMessageState()
session.mailFrom = line.slice('MAIL FROM:'.length).trim()
reply('250 OK')
continue
}
if (normalized.startsWith('RCPT TO:')) {
session.rcptTo.push(line.slice('RCPT TO:'.length).trim())
reply('250 OK')
continue
}
if (normalized === 'DATA') {
session.dataMode = true
session.data = ''
reply('354 End data with <CR><LF>.<CR><LF>')
continue
}
if (normalized === 'RSET') {
resetMessageState()
reply('250 OK')
continue
}
if (normalized === 'NOOP') {
reply('250 OK')
continue
}
if (normalized === 'QUIT') {
reply('221 Bye')
socket.end()
return
}
reply('250 OK')
}
}
socket.on('data', (chunk) => {
session.buffer += chunk
void flushBuffer().catch((error) => {
console.error(error?.stack ?? String(error))
socket.destroy(error)
})
})
socket.on('error', () => {})
reply('220 localhost ESMTP ready')
})
server.listen(port, '127.0.0.1', () => {
console.log(`SMTP capture listening on 127.0.0.1:${port}`)
})
async function shutdown() {
server.close()
await writeQueue.catch(() => {})
}
process.on('SIGINT', () => {
void shutdown().finally(() => process.exit(0))
})
process.on('SIGTERM', () => {
void shutdown().finally(() => process.exit(0))
})

View File

@@ -0,0 +1,316 @@
param(
[string]$AdminUsername = 'e2e_admin',
[string]$AdminPassword = 'E2EAdmin@123456',
[string]$AdminEmail = 'e2e_admin@example.com',
[int]$BrowserPort = 0
)
$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'
$serverExePath = Join-Path $env:TEMP ("ums-server-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir | Out-Null
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 120,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)][string]$WorkingDirectory
)
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
} else {
$process = Start-Process `
-FilePath $FilePath `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
}
return [pscustomobject]@{
Name = $Name
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Stop-ManagedProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
try {
taskkill /PID $Handle.Process.Id /T /F *> $null
} catch {
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Show-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if (Test-Path $Handle.StdOut) {
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
}
if (Test-Path $Handle.StdErr) {
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
}
}
function Remove-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
}
$backendHandle = $null
$frontendHandle = $null
$startedBackend = $false
$startedFrontend = $false
$adminInitialized = $false
try {
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
go build -o $serverExePath .\cmd\server\main.go
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
}
$backendWasRunning = Test-UrlReady -Url 'http://127.0.0.1:8080/health'
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
$env:UMS_ADMIN_USERNAME = $AdminUsername
$env:UMS_ADMIN_PASSWORD = $AdminPassword
$env:UMS_ADMIN_EMAIL = $AdminEmail
$env:UMS_ADMIN_RESET_PASSWORD = 'true'
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$initOutput = go run .\tools\init_admin.go 2>&1 | Out-String
$initExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($initExitCode -eq 0) {
$adminInitialized = $true
} else {
$verifyOutput = go run .\tools\verify_admin.go 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -and $verifyOutput -match 'password valid: True|password valid: true') {
Write-Host 'init_admin fallback: existing admin credentials verified'
$adminInitialized = $true
} else {
Write-Host $initOutput
}
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_RESET_PASSWORD -ErrorAction SilentlyContinue
}
if (-not $adminInitialized -and -not $backendWasRunning) {
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend-bootstrap' `
-FilePath $serverExePath `
-WorkingDirectory $projectRoot
$startedBackend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend bootstrap'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
$backendHandle = $null
Start-Sleep -Seconds 1
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
$env:UMS_ADMIN_USERNAME = $AdminUsername
$env:UMS_ADMIN_PASSWORD = $AdminPassword
$env:UMS_ADMIN_EMAIL = $AdminEmail
$env:UMS_ADMIN_RESET_PASSWORD = 'true'
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$initOutput = go run .\tools\init_admin.go 2>&1 | Out-String
$initExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($initExitCode -eq 0) {
$adminInitialized = $true
} else {
$verifyOutput = go run .\tools\verify_admin.go 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -and $verifyOutput -match 'password valid: True|password valid: true') {
Write-Host 'init_admin fallback: existing admin credentials verified'
$adminInitialized = $true
} else {
Write-Host $initOutput
}
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_ADMIN_RESET_PASSWORD -ErrorAction SilentlyContinue
}
}
if (-not $adminInitialized) {
throw 'init_admin failed'
}
if (-not $backendWasRunning) {
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend' `
-FilePath $serverExePath `
-ArgumentList @() `
-WorkingDirectory $projectRoot
try {
Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
}
if (-not (Test-UrlReady -Url 'http://127.0.0.1:3000')) {
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', '3000') `
-WorkingDirectory $frontendRoot
$startedFrontend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:3000' -Label 'frontend'
} catch {
Show-ManagedProcessLogs $frontendHandle
throw
}
}
$env:E2E_LOGIN_USERNAME = $AdminUsername
$env:E2E_LOGIN_PASSWORD = $AdminPassword
Push-Location $frontendRoot
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') -Port $BrowserPort
} finally {
Pop-Location
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
}
} finally {
if ($startedFrontend) {
Stop-ManagedProcess $frontendHandle
Remove-ManagedProcessLogs $frontendHandle
}
if ($startedBackend) {
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
}
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
}

View File

@@ -0,0 +1,205 @@
param(
[int]$BrowserPort = 0
)
$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'
$serverExePath = Join-Path $env:TEMP ("ums-server-smoke-" + [guid]::NewGuid().ToString('N') + '.exe')
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir | Out-Null
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 120,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)][string]$WorkingDirectory
)
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
} else {
$process = Start-Process `
-FilePath $FilePath `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
}
return [pscustomobject]@{
Name = $Name
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Stop-ManagedProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
try {
taskkill /PID $Handle.Process.Id /T /F *> $null
} catch {
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Show-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if (Test-Path $Handle.StdOut) {
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
}
if (Test-Path $Handle.StdErr) {
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
}
}
function Remove-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
}
$backendHandle = $null
$frontendHandle = $null
$startedBackend = $false
$startedFrontend = $false
try {
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
go build -o $serverExePath .\cmd\server\main.go
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
}
if (-not (Test-UrlReady -Url 'http://127.0.0.1:8080/health')) {
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend-smoke' `
-FilePath $serverExePath `
-WorkingDirectory $projectRoot
$startedBackend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend smoke'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
}
if (-not (Test-UrlReady -Url 'http://127.0.0.1:3000')) {
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend-smoke' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', '3000') `
-WorkingDirectory $frontendRoot
$startedFrontend = $true
try {
Wait-UrlReady -Url 'http://127.0.0.1:3000' -Label 'frontend smoke'
} catch {
Show-ManagedProcessLogs $frontendHandle
throw
}
}
Push-Location $frontendRoot
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') -Port $BrowserPort
} finally {
Pop-Location
}
} finally {
if ($startedFrontend) {
Stop-ManagedProcess $frontendHandle
Remove-ManagedProcessLogs $frontendHandle
}
if ($startedBackend) {
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
}
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
param(
[int]$Port = 0,
[string[]]$Command = @('node', './scripts/run-cdp-smoke.mjs')
)
$ErrorActionPreference = 'Stop'
if (-not $Command -or $Command.Count -eq 0) {
throw 'Command must not be empty'
}
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 60,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$listener.Start()
try {
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Resolve-BrowserPath {
if ($env:E2E_BROWSER_PATH) {
return $env:E2E_BROWSER_PATH
}
if ($env:CHROME_HEADLESS_SHELL_PATH) {
return $env:CHROME_HEADLESS_SHELL_PATH
}
if ($env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
return $env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
}
$baseDir = Join-Path $env:LOCALAPPDATA 'ms-playwright'
$candidate = Get-ChildItem $baseDir -Directory -Filter 'chromium_headless_shell-*' |
Sort-Object Name -Descending |
Select-Object -First 1
if ($candidate) {
return (Join-Path $candidate.FullName 'chrome-headless-shell-win64\chrome-headless-shell.exe')
}
foreach ($fallback in @(
'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'
)) {
if (Test-Path $fallback) {
return $fallback
}
}
throw 'No compatible browser found; set E2E_BROWSER_PATH or CHROME_HEADLESS_SHELL_PATH explicitly if needed'
}
function Test-HeadlessShellBrowser {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath
)
return [System.IO.Path]::GetFileName($BrowserPath).ToLowerInvariant().Contains('headless-shell')
}
function Get-BrowserArguments {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath,
[Parameter(Mandatory = $true)][int]$Port,
[Parameter(Mandatory = $true)][string]$ProfileDir
)
$arguments = @(
"--remote-debugging-port=$Port",
"--user-data-dir=$ProfileDir",
'--no-sandbox'
)
if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) {
$arguments += '--single-process'
} else {
$arguments += @(
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--headless=new'
)
}
$arguments += 'about:blank'
return $arguments
}
function Get-BrowserProcessIds {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath
)
$processName = [System.IO.Path]::GetFileNameWithoutExtension($BrowserPath)
try {
return @(Get-Process -Name $processName -ErrorAction Stop | Select-Object -ExpandProperty Id)
} catch {
return @()
}
}
function Get-BrowserProcessesByProfile {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath,
[Parameter(Mandatory = $true)][string]$ProfileDir
)
$processFileName = [System.IO.Path]::GetFileName($BrowserPath)
$profileFragment = $ProfileDir.ToLowerInvariant()
try {
return @(
Get-CimInstance Win32_Process -Filter ("Name = '{0}'" -f $processFileName) -ErrorAction Stop |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.ToLowerInvariant().Contains($profileFragment)
}
)
} catch {
return @()
}
}
function Get-ChildProcessIds {
param(
[Parameter(Mandatory = $true)][int]$ParentId
)
$pending = [System.Collections.Generic.Queue[int]]::new()
$seen = [System.Collections.Generic.HashSet[int]]::new()
$pending.Enqueue($ParentId)
while ($pending.Count -gt 0) {
$currentParentId = $pending.Dequeue()
try {
$children = @(Get-CimInstance Win32_Process -Filter ("ParentProcessId = {0}" -f $currentParentId) -ErrorAction Stop)
} catch {
$children = @()
}
foreach ($child in $children) {
if ($seen.Add([int]$child.ProcessId)) {
$pending.Enqueue([int]$child.ProcessId)
}
}
}
return @($seen)
}
function Get-BrowserCleanupIds {
param(
[Parameter(Mandatory = $true)]$Handle
)
$ids = [System.Collections.Generic.HashSet[int]]::new()
if ($Handle.Process) {
$null = $ids.Add([int]$Handle.Process.Id)
foreach ($childId in Get-ChildProcessIds -ParentId $Handle.Process.Id) {
$null = $ids.Add([int]$childId)
}
}
foreach ($processInfo in Get-BrowserProcessesByProfile -BrowserPath $Handle.BrowserPath -ProfileDir $Handle.ProfileDir) {
$null = $ids.Add([int]$processInfo.ProcessId)
}
$liveIds = @()
foreach ($processId in $ids) {
try {
Get-Process -Id $processId -ErrorAction Stop | Out-Null
$liveIds += $processId
} catch {
# Process already exited.
}
}
return @($liveIds | Sort-Object -Unique)
}
function Start-BrowserProcess {
param(
[Parameter(Mandatory = $true)][string]$BrowserPath,
[Parameter(Mandatory = $true)][int]$Port,
[Parameter(Mandatory = $true)][string]$ProfileDir
)
$baselineIds = Get-BrowserProcessIds -BrowserPath $BrowserPath
$arguments = Get-BrowserArguments -BrowserPath $BrowserPath -Port $Port -ProfileDir $ProfileDir
$stdoutPath = Join-Path $ProfileDir 'browser-stdout.log'
$stderrPath = Join-Path $ProfileDir 'browser-stderr.log'
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
$process = Start-Process `
-FilePath $BrowserPath `
-ArgumentList $arguments `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
return [pscustomobject]@{
BrowserPath = $BrowserPath
BaselineIds = $baselineIds
ProfileDir = $ProfileDir
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Show-BrowserLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
foreach ($path in @($Handle.StdOut, $Handle.StdErr)) {
if (-not [string]::IsNullOrWhiteSpace($path) -and (Test-Path $path)) {
Get-Content $path -ErrorAction SilentlyContinue
}
}
}
function Stop-BrowserProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
foreach ($cleanupCommand in @(
{ param($id) taskkill /PID $id /T /F *> $null },
{ param($id) Stop-Process -Id $id -Force -ErrorAction Stop }
)) {
try {
& $cleanupCommand $Handle.Process.Id
} catch {
# Ignore cleanup errors here; the residual PID check below is authoritative.
}
}
}
$residualIds = @()
for ($attempt = 0; $attempt -lt 12; $attempt++) {
$residualIds = @(Get-BrowserCleanupIds -Handle $Handle)
foreach ($processId in $residualIds) {
foreach ($cleanupCommand in @(
{ param($id) taskkill /PID $id /T /F *> $null },
{ param($id) Stop-Process -Id $id -Force -ErrorAction Stop }
)) {
try {
& $cleanupCommand $processId
} catch {
# Ignore per-process cleanup errors during retry loop.
}
}
}
Start-Sleep -Milliseconds 500
$residualIds = @(Get-BrowserCleanupIds -Handle $Handle)
if ($residualIds.Count -eq 0) {
break
}
}
if ($residualIds.Count -gt 0) {
throw "browser cleanup leaked PIDs: $($residualIds -join ', ')"
}
}
function Remove-BrowserLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
$paths = @($Handle.StdOut, $Handle.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($paths.Count -gt 0) {
Remove-Item $paths -Force -ErrorAction SilentlyContinue
}
}
$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'
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"
$browserCdpBaseUrl = "http://127.0.0.1:$Port"
$browserHandle = $null
try {
for ($attempt = 1; $attempt -le 2; $attempt++) {
Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue
$browserHandle = Start-BrowserProcess -BrowserPath $browserPath -Port $Port -ProfileDir $profileDir
try {
Wait-UrlReady -Url $browserReadyUrl -Label "browser CDP endpoint (attempt $attempt)"
Write-Host "CDP endpoint ready: $browserReadyUrl"
break
} catch {
Show-BrowserLogs $browserHandle
Stop-BrowserProcess $browserHandle
Remove-BrowserLogs $browserHandle
$browserHandle = $null
if ($attempt -eq 2) {
throw
}
}
}
if (-not $env:E2E_COMMAND_TIMEOUT_MS) {
$env:E2E_COMMAND_TIMEOUT_MS = '120000'
}
$env:E2E_SKIP_BROWSER_LAUNCH = '1'
$env:E2E_CDP_PORT = "$Port"
$env:E2E_CDP_BASE_URL = $browserCdpBaseUrl
$env:E2E_PLAYWRIGHT_CDP_URL = $browserCdpBaseUrl
$env:E2E_EXTERNAL_CDP = '1'
$commandName = $Command[0]
$commandArgs = @()
if ($Command.Count -gt 1) {
$commandArgs = $Command[1..($Command.Count - 1)]
}
Write-Host "Launching command: $commandName $($commandArgs -join ' ')"
& $commandName @commandArgs
if ($LASTEXITCODE -ne 0) {
throw "command failed with exit code $LASTEXITCODE"
}
} finally {
Stop-BrowserProcess $browserHandle
Remove-BrowserLogs $browserHandle
Remove-Item Env:E2E_SKIP_BROWSER_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:E2E_CDP_PORT -ErrorAction SilentlyContinue
Remove-Item Env:E2E_CDP_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_PLAYWRIGHT_CDP_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXTERNAL_CDP -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue
}

View File

@@ -0,0 +1,297 @@
param(
[string]$AdminUsername = 'e2e_admin',
[string]$AdminPassword = 'E2EAdmin@123456',
[string]$AdminEmail = 'e2e_admin@example.com',
[int]$BrowserPort = 0,
[int]$BackendPort = 0,
[int]$FrontendPort = 0
)
$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'
$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'))
$e2eDataRoot = Join-Path $e2eRunRoot 'data'
$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db'
$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl'
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$listener.Start()
try {
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Test-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
} catch {
return $false
}
}
function Wait-UrlReady {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$Label,
[int]$RetryCount = 120,
[int]$DelayMs = 500
)
for ($i = 0; $i -lt $RetryCount; $i++) {
if (Test-UrlReady -Url $Url) {
return
}
Start-Sleep -Milliseconds $DelayMs
}
throw "$Label did not become ready: $Url"
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)][string]$WorkingDirectory
)
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
} else {
$process = Start-Process `
-FilePath $FilePath `
-WorkingDirectory $WorkingDirectory `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
}
return [pscustomobject]@{
Name = $Name
Process = $process
StdOut = $stdoutPath
StdErr = $stderrPath
}
}
function Stop-ManagedProcess {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if ($Handle.Process -and -not $Handle.Process.HasExited) {
try {
taskkill /PID $Handle.Process.Id /T /F *> $null
} catch {
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Show-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
if (Test-Path $Handle.StdOut) {
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
}
if (Test-Path $Handle.StdErr) {
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
}
}
function Remove-ManagedProcessLogs {
param(
[Parameter(Mandatory = $false)]$Handle
)
if (-not $Handle) {
return
}
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
}
$backendHandle = $null
$frontendHandle = $null
$smtpHandle = $null
$selectedBackendPort = if ($BackendPort -gt 0) { $BackendPort } else { Get-FreeTcpPort }
$selectedFrontendPort = if ($FrontendPort -gt 0) { $FrontendPort } else { Get-FreeTcpPort }
$selectedSMTPPort = Get-FreeTcpPort
$backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
try {
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
go build -o $serverExePath .\cmd\server\main.go
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
}
$env:UMS_SERVER_PORT = "$selectedBackendPort"
$env:UMS_DATABASE_SQLITE_PATH = $e2eDbPath
$env:UMS_SERVER_MODE = 'debug'
$env:UMS_PASSWORD_RESET_SITE_URL = $frontendBaseUrl
$env:UMS_CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
$env:UMS_LOGGING_OUTPUT = 'stdout'
$env:UMS_EMAIL_HOST = '127.0.0.1'
$env:UMS_EMAIL_PORT = "$selectedSMTPPort"
$env:UMS_EMAIL_FROM_EMAIL = 'noreply@test.local'
$env:UMS_EMAIL_FROM_NAME = 'UMS E2E'
Write-Host "playwright e2e backend: $backendBaseUrl"
Write-Host "playwright e2e frontend: $frontendBaseUrl"
Write-Host "playwright e2e smtp: 127.0.0.1:$selectedSMTPPort"
Write-Host "playwright e2e sqlite: $e2eDbPath"
$smtpHandle = Start-ManagedProcess `
-Name 'ums-smtp-capture' `
-FilePath 'node' `
-ArgumentList @((Join-Path $PSScriptRoot 'mock-smtp-capture.mjs'), '--port', "$selectedSMTPPort", '--output', $smtpCaptureFile) `
-WorkingDirectory $frontendRoot
Start-Sleep -Milliseconds 500
if ($smtpHandle.Process -and $smtpHandle.Process.HasExited) {
Show-ManagedProcessLogs $smtpHandle
throw 'smtp capture server failed to start'
}
$backendHandle = Start-ManagedProcess `
-Name 'ums-backend-playwright' `
-FilePath $serverExePath `
-WorkingDirectory $projectRoot
try {
Wait-UrlReady -Url "$backendBaseUrl/health" -Label 'backend'
} catch {
Show-ManagedProcessLogs $backendHandle
throw
}
$env:VITE_API_PROXY_TARGET = $backendBaseUrl
$env:VITE_API_BASE_URL = '/api/v1'
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend-playwright' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', "$selectedFrontendPort") `
-WorkingDirectory $frontendRoot
try {
Wait-UrlReady -Url $frontendBaseUrl -Label 'frontend'
} catch {
Show-ManagedProcessLogs $frontendHandle
throw
}
$env:E2E_LOGIN_USERNAME = $AdminUsername
$env:E2E_LOGIN_PASSWORD = $AdminPassword
$env:E2E_LOGIN_EMAIL = $AdminEmail
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
$env:E2E_EXTERNAL_WEB_SERVER = '1'
$env:E2E_BASE_URL = $frontendBaseUrl
$env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile
Push-Location $frontendRoot
try {
$lastError = $null
for ($attempt = 1; $attempt -le 2; $attempt++) {
try {
& (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 2) {
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
}
}
if ($lastError) {
throw $lastError
}
} finally {
Pop-Location
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_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_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
}
} finally {
Stop-ManagedProcess $frontendHandle
Remove-ManagedProcessLogs $frontendHandle
Stop-ManagedProcess $backendHandle
Remove-ManagedProcessLogs $backendHandle
Stop-ManagedProcess $smtpHandle
Remove-ManagedProcessLogs $smtpHandle
Remove-Item Env:UMS_SERVER_PORT -ErrorAction SilentlyContinue
Remove-Item Env:UMS_DATABASE_SQLITE_PATH -ErrorAction SilentlyContinue
Remove-Item Env:UMS_SERVER_MODE -ErrorAction SilentlyContinue
Remove-Item Env:UMS_PASSWORD_RESET_SITE_URL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue
Remove-Item Env:UMS_LOGGING_OUTPUT -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_HOST -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_PORT -ErrorAction SilentlyContinue
Remove-Item Env:UMS_EMAIL_FROM_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:UMS_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 $serverExePath -Force -ErrorAction SilentlyContinue
Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import { parseCLI, startVitest } from 'vitest/node'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const { filter, options } = parseCLI(['vitest', ...process.argv.slice(2)])
const { coverage: coverageOptions, ...cliOptions } = options
const baseCoverage = {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.interface.ts',
'src/test/**',
'src/main.tsx',
'src/vite-env.d.ts',
],
}
function resolveCoverageConfig(option) {
if (!option) {
return {
...baseCoverage,
enabled: false,
}
}
if (option === true) {
return {
...baseCoverage,
enabled: true,
}
}
return {
...baseCoverage,
...option,
enabled: option.enabled ?? true,
}
}
const ctx = await startVitest(
'test',
filter,
{
...cliOptions,
root,
config: false,
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
coverage: resolveCoverageConfig(coverageOptions),
pool: cliOptions.pool ?? 'threads',
fileParallelism: cliOptions.fileParallelism ?? false,
maxWorkers: cliOptions.maxWorkers ?? 1,
testTimeout: cliOptions.testTimeout ?? 10000,
hookTimeout: cliOptions.hookTimeout ?? 10000,
clearMocks: true,
},
{
plugins: [react()],
resolve: {
preserveSymlinks: true,
alias: {
'@': path.resolve(root, 'src'),
},
},
},
)
if (!ctx?.shouldKeepServer()) {
await ctx?.exit()
}