test: add one-command browser runner

This commit is contained in:
Codex
2026-08-28 15:47:47 +09:00
parent 9a7401c023
commit 95f2ca19a6
2 changed files with 222 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
[CmdletBinding()]
param(
[ValidateRange(1024, 65535)]
[int]$Port = 4193,
[ValidateRange(10, 900)]
[int]$TimeoutSeconds = 180,
[string]$BrowserPath = ""
)
$ErrorActionPreference = 'Stop'
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$serveScript = Join-Path $PSScriptRoot 'serve.ps1'
$serverProcess = $null
$browserProcess = $null
$socket = $null
$exitCode = 1
$profilePath = Join-Path ([System.IO.Path]::GetTempPath()) ("micarrsim-tests-" + [guid]::NewGuid().ToString('N'))
function Resolve-BrowserPath {
param([string]$RequestedPath)
if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) {
$resolved = [System.IO.Path]::GetFullPath($RequestedPath)
if (-not [System.IO.File]::Exists($resolved)) {
throw "Browser executable not found: $resolved"
}
return $resolved
}
$candidates = @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Google\Chrome\Application\chrome.exe',
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
)
foreach ($candidate in $candidates) {
if ([System.IO.File]::Exists($candidate)) { return $candidate }
}
throw 'Microsoft Edge or Google Chrome is required to run browser tests.'
}
function Get-FreeLoopbackPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Wait-ForHttp {
param([string]$Url, [int]$Seconds)
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
do {
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { return }
}
catch {
Start-Sleep -Milliseconds 100
}
} while ([DateTime]::UtcNow -lt $deadline)
throw "Timed out waiting for $Url"
}
function Send-CdpMessage {
param([System.Net.WebSockets.ClientWebSocket]$Client, [hashtable]$Message)
$json = $Message | ConvertTo-Json -Compress -Depth 10
[byte[]]$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$segment = [System.ArraySegment[byte]]::new($bytes)
$Client.SendAsync(
$segment,
[System.Net.WebSockets.WebSocketMessageType]::Text,
$true,
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult() | Out-Null
}
function Receive-CdpMessage {
param([System.Net.WebSockets.ClientWebSocket]$Client)
$buffer = New-Object byte[] 65536
$stream = [System.IO.MemoryStream]::new()
try {
do {
$segment = [System.ArraySegment[byte]]::new($buffer)
$received = $Client.ReceiveAsync(
$segment,
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult()
if ($received.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
throw 'Browser DevTools connection closed before tests completed.'
}
$stream.Write($buffer, 0, $received.Count)
} while (-not $received.EndOfMessage)
return [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) | ConvertFrom-Json
}
finally {
$stream.Dispose()
}
}
try {
$browser = Resolve-BrowserPath $BrowserPath
$debugPort = Get-FreeLoopbackPort
$serverUrl = "http://127.0.0.1:$Port/"
$testUrl = "${serverUrl}tests/"
$serverArguments = @(
'-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass',
'-File', ('"' + $serveScript + '"'), '-Port', [string]$Port, '-NoBrowser'
)
$serverProcess = Start-Process -FilePath 'powershell.exe' -ArgumentList $serverArguments -WindowStyle Hidden -PassThru
Wait-ForHttp -Url $testUrl -Seconds 15
[System.IO.Directory]::CreateDirectory($profilePath) | Out-Null
$browserArguments = @(
'--headless=new', '--disable-gpu', '--no-first-run', '--disable-background-networking',
"--remote-debugging-port=$debugPort", ('--user-data-dir="' + $profilePath + '"'), $testUrl
)
$browserProcess = Start-Process -FilePath $browser -ArgumentList $browserArguments -WindowStyle Hidden -PassThru
Wait-ForHttp -Url "http://127.0.0.1:$debugPort/json/version" -Seconds 15
$page = $null
$pageDeadline = [DateTime]::UtcNow.AddSeconds(15)
do {
$pages = Invoke-RestMethod -Uri "http://127.0.0.1:$debugPort/json/list" -TimeoutSec 2
$page = $pages | Where-Object { $_.type -eq 'page' -and $_.url -like "${testUrl}*" } | Select-Object -First 1
if (-not $page) { Start-Sleep -Milliseconds 100 }
} while (-not $page -and [DateTime]::UtcNow -lt $pageDeadline)
if (-not $page) { throw 'Could not find the browser test page.' }
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
$socket.ConnectAsync(
[uri]$page.webSocketDebuggerUrl,
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult() | Out-Null
$timeoutMilliseconds = $TimeoutSeconds * 1000
$expression = @"
new Promise((resolve) => {
const deadline = Date.now() + $timeoutMilliseconds;
const poll = () => {
const status = document.documentElement.dataset.testStatus;
if (status || Date.now() >= deadline) {
resolve({
status: status || 'timeout',
summary: document.querySelector('#test-summary')?.textContent || 'No test summary',
failures: [...document.querySelectorAll('.test-result--fail')].map((item) => item.innerText),
});
return;
}
setTimeout(poll, 100);
};
poll();
})
"@
Send-CdpMessage -Client $socket -Message @{
id = 1
method = 'Runtime.evaluate'
params = @{ expression = $expression; awaitPromise = $true; returnByValue = $true }
}
$response = $null
do {
$candidate = Receive-CdpMessage -Client $socket
if ($candidate.id -eq 1) { $response = $candidate }
} while (-not $response)
if ($response.error) { throw ($response.error | ConvertTo-Json -Compress -Depth 8) }
if ($response.result.exceptionDetails) {
throw ($response.result.exceptionDetails | ConvertTo-Json -Compress -Depth 8)
}
$testResult = $response.result.result.value
Write-Host $testResult.summary
foreach ($failure in @($testResult.failures)) { Write-Host $failure -ForegroundColor Red }
if ($testResult.status -eq 'pass') {
Write-Host 'Browser tests passed.' -ForegroundColor Green
$exitCode = 0
}
elseif ($testResult.status -eq 'timeout') {
Write-Host "Browser tests did not finish within $TimeoutSeconds seconds." -ForegroundColor Red
}
else {
Write-Host 'Browser tests failed.' -ForegroundColor Red
}
}
catch {
Write-Error $_
}
finally {
if ($socket) {
try {
if ($socket.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
$socket.CloseAsync(
[System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure,
'test complete',
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult() | Out-Null
}
}
catch { }
$socket.Dispose()
}
foreach ($process in @($browserProcess, $serverProcess)) {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Milliseconds 200
$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
$resolvedProfile = [System.IO.Path]::GetFullPath($profilePath)
if (
$resolvedProfile.StartsWith($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($resolvedProfile).StartsWith('micarrsim-tests-', [System.StringComparison]::Ordinal)
) {
Remove-Item -LiteralPath $resolvedProfile -Recurse -Force -ErrorAction SilentlyContinue
}
}
exit $exitCode
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-tests.ps1" %*
exit /b %ERRORLEVEL%