feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
185
frontend/admin/scripts/mock-smtp-capture.mjs
Normal file
185
frontend/admin/scripts/mock-smtp-capture.mjs
Normal 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))
|
||||
})
|
||||
316
frontend/admin/scripts/run-cdp-auth-smoke.ps1
Normal file
316
frontend/admin/scripts/run-cdp-auth-smoke.ps1
Normal 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
|
||||
}
|
||||
205
frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1
Normal file
205
frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1
Normal 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
|
||||
}
|
||||
1624
frontend/admin/scripts/run-cdp-smoke.mjs
Normal file
1624
frontend/admin/scripts/run-cdp-smoke.mjs
Normal file
File diff suppressed because it is too large
Load Diff
397
frontend/admin/scripts/run-cdp-smoke.ps1
Normal file
397
frontend/admin/scripts/run-cdp-smoke.ps1
Normal 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
|
||||
}
|
||||
297
frontend/admin/scripts/run-playwright-auth-e2e.ps1
Normal file
297
frontend/admin/scripts/run-playwright-auth-e2e.ps1
Normal 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
|
||||
}
|
||||
1176
frontend/admin/scripts/run-playwright-cdp-e2e.mjs
Normal file
1176
frontend/admin/scripts/run-playwright-cdp-e2e.mjs
Normal file
File diff suppressed because it is too large
Load Diff
79
frontend/admin/scripts/run-vitest.mjs
Normal file
79
frontend/admin/scripts/run-vitest.mjs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user