From 95f2ca19a6feb30aeabe5da3e80fbadecab2a80c Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 15:47:47 +0900 Subject: [PATCH] test: add one-command browser runner --- scripts/run-tests.ps1 | 219 ++++++++++++++++++++++++++++++++++++++++++ test.cmd | 3 + 2 files changed, 222 insertions(+) create mode 100644 scripts/run-tests.ps1 create mode 100644 test.cmd diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 new file mode 100644 index 0000000..e3084e9 --- /dev/null +++ b/scripts/run-tests.ps1 @@ -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 diff --git a/test.cmd b/test.cmd new file mode 100644 index 0000000..0b7fce7 --- /dev/null +++ b/test.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-tests.ps1" %* +exit /b %ERRORLEVEL%