#requires -Version 5.1 <# .SYNOPSIS Deshab QEMU 自动化测试公共库(tests/qemu/lib/QemuTest.ps1) .DESCRIPTION 提供: - QEMU 会话管理(启动 / 计时杀进程 / 串口日志收集) - 日志断言引擎(必须匹配 / 禁止匹配 / 顺序匹配 / 计数匹配) - QEMU monitor TCP 客户端(sendkey / mouse_move / screendump) - 场景准备(firstInit.txt 与 AUTOEXEC.BAT 备份-替换-恢复 + SATA 镜像重建) - 用例结果报告(PASS/FAIL/MANUAL/SKIP,失败时输出日志尾部上下文) 兼容 PowerShell 5.1。所有临时文件写入 .build_tmp\tests\。 #> # ---------------- 路径 ---------------- $script:RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) $script:BuildTmp = Join-Path $script:RepoRoot '.build_tmp' $script:TestWorkDir = Join-Path $script:BuildTmp 'tests' $script:IsoDir = Join-Path $script:RepoRoot 'ISO' $script:SystemDir = Join-Path $script:RepoRoot 'SYSTEM' $script:Config = $null # 场景备份(模块级,保证只备份一次、恢复幂等) $script:FirstInitBackup = $null $script:AutoexecBackup = $null # ================================================================ # 环境初始化 # ================================================================ function Initialize-QemuTest { param([switch]$Build) # --- 定位 QEMU --- $qemu = $null $cmd = Get-Command 'qemu-system-x86_64' -ErrorAction SilentlyContinue if ($cmd) { $qemu = $cmd.Source } foreach ($c in @('C:\Program Files\qemu\qemu-system-x86_64.exe', 'C:\Program Files (x86)\qemu\qemu-system-x86_64.exe', 'C:\msys64\mingw64\bin\qemu-system-x86_64.exe', 'C:\msys64\ucrt64\bin\qemu-system-x86_64.exe')) { if (-not $qemu -and (Test-Path $c)) { $qemu = $c } } if (-not $qemu) { throw 'qemu-system-x86_64 not found (install QEMU or add to PATH)' } # --- 定位 OVMF --- $ovmf = $null foreach ($c in @('C:\Program Files\qemu\share\edk2-x86_64-code.fd', 'C:\Program Files\qemu\share\OVMF_CODE.fd', 'C:\msys64\mingw64\share\edk2\ovmf\OVMF_CODE.fd', 'C:\msys64\ucrt64\share\edk2\ovmf\OVMF_CODE.fd')) { if (Test-Path $c) { $ovmf = $c; break } } if (-not $ovmf) { throw 'OVMF UEFI firmware not found under QEMU install directory' } New-Item -ItemType Directory -Force -Path $script:TestWorkDir | Out-Null $img = Join-Path $script:IsoDir 'deshab.img' $sata = Join-Path $script:BuildTmp 'sata_fat32_dsk.img' $nvme = Join-Path $script:BuildTmp 'nvme_test.img' $usb = Join-Path $script:BuildTmp 'usb_test.img' if ($Build -or -not (Test-Path $img)) { Write-Host '[init] building images via build.ps1 ...' Stop-ResidualQemu & powershell -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot 'build.ps1') | Out-Null if ($LASTEXITCODE -ne 0) { throw "build.ps1 failed with exit code $LASTEXITCODE" } } if (-not (Test-Path $img)) { throw "image missing: $img (run with -Build)" } if (-not (Test-Path $sata)) { Write-Host '[init] sata_fat32_dsk.img missing, regenerating via mkfat32.ps1 ...' Invoke-MkFat32 } if (-not (Test-Path $sata)) { throw "sata image missing: $sata" } $nvmeOk = $null; if (Test-Path $nvme) { $nvmeOk = $nvme } $usbOk = $null; if (Test-Path $usb) { $usbOk = $usb } $script:Config = [pscustomobject]@{ QemuExe = $qemu Ovmf = $ovmf Img = $img SataImg = $sata NvmeImg = $nvmeOk UsbImg = $usbOk } Write-Host "[init] QEMU: $qemu" Write-Host "[init] OVMF: $ovmf" Write-Host "[init] IMG: $img" return $script:Config } # ================================================================ # 进程管理 # ================================================================ # 清理持有本项目镜像的残留 QEMU(deshab.img 被锁会导致 build/启动失败)。 # 只杀命令行中引用本仓库路径的 qemu 进程,避免误伤他人虚拟机。 function Stop-ResidualQemu { $procs = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" -ErrorAction SilentlyContinue foreach ($p in $procs) { if ($p.CommandLine -and $p.CommandLine.ToLower().Contains('deshab')) { Write-Host "[qemu] killing residual qemu pid=$($p.ProcessId)" Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } } } function Get-FreeMonitorPort { for ($port = 4444; $port -lt 4544; $port++) { $listener = $null try { $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $port) $listener.Start() $listener.Stop() return $port } catch { if ($listener) { try { $listener.Stop() } catch {} } } } throw 'no free TCP port available for QEMU monitor' } # ================================================================ # QEMU 会话 # ================================================================ function Start-QemuSession { param( [Parameter(Mandatory)][string]$Name, [string]$Accel = 'whpx', [switch]$NoSata, [switch]$NoNvme, [switch]$NoUsb, [switch]$NoNet, [string]$NetModel = 'e1000', [switch]$NoMonitor, [string]$Cpu = '', [string[]]$ExtraArgs = @() ) if (-not $script:Config) { throw 'call Initialize-QemuTest first' } $cfg = $script:Config $tag = "deshabtest-$Name-" + [guid]::NewGuid().ToString('N').Substring(0, 8) $stamp = Get-Date -Format 'HHmmss' $log = Join-Path $script:TestWorkDir "$Name-$stamp.log" $bat = Join-Path $script:TestWorkDir "$Name-$stamp.bat" # WHPX 不支持 nested VMX:默认关闭 guest VMX CPUID(与 ISO/run_qemu.bat 一致)。 # -Cpu 可显式覆盖(如 'max' 用于探测 nested VMX 可用性)。 $cpu = 'qemu64' if ($Accel -eq 'whpx') { $cpu = 'qemu64,-vmx' } if ($Cpu -ne '') { $cpu = $Cpu } $qargs = @( '-accel', $Accel, '-machine', 'q35', '-m', '2G', '-cpu', $cpu, '-serial', 'stdio', '-display', 'none', '-no-reboot', '-name', $tag, '-drive', ('if=pflash,format=raw,readonly=on,file="{0}"' -f $cfg.Ovmf), '-drive', ('format=raw,file="{0}",if=virtio' -f $cfg.Img) ) $port = 0 if (-not $NoMonitor) { $port = Get-FreeMonitorPort $qargs += @('-monitor', "tcp:127.0.0.1:$port,server,nowait") } if (-not $NoSata) { $qargs += @('-drive', ('id=sata0,format=raw,file="{0}",if=none' -f $cfg.SataImg), '-device', 'ide-hd,drive=sata0,bus=ide.0') } if (-not $NoNvme -and $cfg.NvmeImg) { $qargs += @('-drive', ('if=none,id=nvme0,file="{0}",format=raw' -f $cfg.NvmeImg), '-device', 'nvme,drive=nvme0,serial=deadbeef') } if (-not $NoUsb -and $cfg.UsbImg) { $qargs += @('-device', 'qemu-xhci,id=xhci0', '-drive', ('id=usb0,if=none,file="{0}",format=raw' -f $cfg.UsbImg), '-device', 'usb-storage,bus=xhci0.0,drive=usb0') } if (-not $NoNet) { $qargs += @('-netdev', 'user,id=net0') if ($NetModel -eq 'e1000') { $qargs += @('-device', 'e1000,netdev=net0,mac=52:54:00:12:34:56') } elseif ($NetModel -eq 'virtio-net-pci') { $qargs += @('-device', 'virtio-net-pci,netdev=net0') } } foreach ($x in $ExtraArgs) { $qargs += $x } $qargs += @('-boot', 'menu=on') # 已知坑:Start-Process 下 -serial file: 不生效;用 cmd 批处理重定向 stdio。 $cmdline = '"' + $cfg.QemuExe + '" ' + ($qargs -join ' ') $batContent = "@echo off`r`n$cmdline > `"$log`" 2>&1`r`n" [System.IO.File]::WriteAllText($bat, $batContent) Stop-ResidualQemu Start-Process -FilePath $bat -WindowStyle Hidden $session = [pscustomobject]@{ Name = $Name Tag = $tag BatPath = $bat LogPath = $log MonitorPort = $port Accel = $Accel StartTime = Get-Date } Write-Host "[qemu] session '$Name' started (monitor=$port, log=$log)" return $session } function Test-QemuSessionExited { param([Parameter(Mandatory)]$Session) $procs = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" -ErrorAction SilentlyContinue foreach ($p in $procs) { if ($p.CommandLine -and $p.CommandLine.Contains($Session.Tag)) { return $false } } return $true } function Stop-QemuSession { param([Parameter(Mandatory)]$Session) $procs = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" -ErrorAction SilentlyContinue foreach ($p in $procs) { if ($p.CommandLine -and $p.CommandLine.Contains($Session.Tag)) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } } Start-Sleep -Milliseconds 500 } # ================================================================ # 串口日志 # ================================================================ function Read-QemuLogText { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path $Path)) { return '' } $fs = $null try { $fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') $sr = New-Object System.IO.StreamReader($fs, [System.Text.Encoding]::UTF8) $text = $sr.ReadToEnd() $sr.Close() return $text } catch { return '' } finally { if ($fs) { $fs.Close() } } } # 轮询串口日志直到任一 pattern 出现 / QEMU 退出 / 超时。返回匹配到的 pattern 或 $null。 function Wait-QemuLog { param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string[]]$Patterns, [int]$TimeoutSeconds = 90, [int]$PollMs = 1000 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $text = Read-QemuLogText $Session.LogPath foreach ($p in $Patterns) { if ($text.Contains($p)) { return $p } } if (Test-QemuSessionExited $Session) { Write-Host '[qemu] session exited unexpectedly' return $null } Start-Sleep -Milliseconds $PollMs } Write-Host "[qemu] wait timeout after ${TimeoutSeconds}s (patterns: $($Patterns -join ' | '))" return $null } # ================================================================ # QEMU monitor(TCP) # ================================================================ function Invoke-QemuMonitor { param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string]$Command, [int]$SettleMs = 250 ) if (-not $Session.MonitorPort) { throw 'session started with -NoMonitor' } $client = New-Object System.Net.Sockets.TcpClient $client.ReceiveTimeout = 3000 $client.SendTimeout = 3000 $client.Connect('127.0.0.1', [int]$Session.MonitorPort) try { $stream = $client.GetStream() $buf = New-Object byte[] 8192 Start-Sleep -Milliseconds 200 while ($stream.DataAvailable) { [void]$stream.Read($buf, 0, $buf.Length) } # banner $writer = New-Object System.IO.StreamWriter($stream) $writer.AutoFlush = $true $writer.WriteLine($Command) Start-Sleep -Milliseconds $SettleMs $resp = '' while ($stream.DataAvailable) { $n = $stream.Read($buf, 0, $buf.Length) $resp += [System.Text.Encoding]::ASCII.GetString($buf, 0, $n) } return $resp } finally { $client.Close() } } function ConvertTo-QemuKeyName { param([Parameter(Mandatory)][char]$C) $i = [int]$C if ($i -ge 97 -and $i -le 122) { return [string]$C } # a-z if ($i -ge 65 -and $i -le 90) { return 'shift-' + ([string]$C).ToLower() } # A-Z if ($i -ge 48 -and $i -le 57) { return [string]$C } # 0-9 switch ($C) { ' ' { return 'spc' } '.' { return 'dot' } ',' { return 'comma' } '/' { return 'slash' } '\' { return 'backslash' } '-' { return 'minus' } '=' { return 'equal' } ';' { return 'semicolon' } ':' { return 'shift-semicolon' } '>' { return 'shift-dot' } '<' { return 'shift-comma' } '_' { return 'shift-minus' } '!' { return 'shift-1' } '"' { return 'shift-apostrophe' } '''' { return 'apostrophe' } '(' { return 'shift-9' } ')' { return 'shift-0' } '?' { return 'shift-slash' } '*' { return 'shift-8' } '+' { return 'shift-equal' } '@' { return 'shift-2' } '#' { return 'shift-3' } '$' { return 'shift-4' } '%' { return 'shift-5' } '^' { return 'shift-6' } '&' { return 'shift-7' } '|' { return 'shift-backslash' } '~' { return 'shift-grave_accent' } } return $null } # 通过 monitor 发送一串按键(单 TCP 连接内完成) function Send-QemuKeys { param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string[]]$Keys, [int]$DelayMs = 120 ) if (-not $Session.MonitorPort) { throw 'session started with -NoMonitor' } $client = New-Object System.Net.Sockets.TcpClient $client.Connect('127.0.0.1', [int]$Session.MonitorPort) try { $stream = $client.GetStream() $buf = New-Object byte[] 4096 Start-Sleep -Milliseconds 200 while ($stream.DataAvailable) { [void]$stream.Read($buf, 0, $buf.Length) } $writer = New-Object System.IO.StreamWriter($stream) $writer.AutoFlush = $true foreach ($k in $Keys) { $writer.WriteLine("sendkey $k") Start-Sleep -Milliseconds $DelayMs } } finally { $client.Close() } } # 输入一段文本(自动映射大小写/符号),可选追加回车 function Send-QemuText { param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string]$Text, [switch]$Enter, [int]$DelayMs = 90 ) $keys = New-Object System.Collections.ArrayList foreach ($c in $Text.ToCharArray()) { $k = ConvertTo-QemuKeyName $c if ($k) { [void]$keys.Add($k) } } if ($Enter) { [void]$keys.Add('ret') } Send-QemuKeys -Session $Session -Keys ($keys.ToArray()) -DelayMs $DelayMs } function Send-QemuMouseMove { param( [Parameter(Mandatory)]$Session, [int]$Dx = 0, [int]$Dy = 0, [string]$Button = '' ) $cmd = "mouse_move $Dx $Dy" [void](Invoke-QemuMonitor -Session $Session -Command $cmd) if ($Button) { [void](Invoke-QemuMonitor -Session $Session -Command "mouse_button $Button") } } function Get-QemuScreenshot { param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)][string]$OutPath ) $resp = Invoke-QemuMonitor -Session $Session -Command "screendump `"$OutPath`"" -SettleMs 600 return (Test-Path $OutPath) } # ================================================================ # 场景准备(firstInit.txt / AUTOEXEC.BAT + SATA 镜像重建) # ================================================================ function Invoke-MkFat32 { $mk = Join-Path $script:BuildTmp 'mkfat32.ps1' if (-not (Test-Path $mk)) { throw "mkfat32.ps1 not found: $mk (run build.ps1 once)" } & powershell -ExecutionPolicy Bypass -File $mk | Out-Null if ($LASTEXITCODE -ne 0) { throw "mkfat32.ps1 failed with exit code $LASTEXITCODE" } } # 写 firstInit.txt(格式 "`n",LF)并重建 SATA 镜像;原文件自动备份 function Set-DeshabFirstInit { param( [Parameter(Mandatory)][int]$First, [Parameter(Mandatory)][int]$DevMode ) $path = Join-Path $script:SystemDir 'system\user\use\firstInit.txt' if ($null -eq $script:FirstInitBackup) { $script:FirstInitBackup = [System.IO.File]::ReadAllBytes($path) } [System.IO.File]::WriteAllText($path, "$First`n$DevMode") Invoke-MkFat32 } function Restore-DeshabFirstInit { $path = Join-Path $script:SystemDir 'system\user\use\firstInit.txt' if ($null -ne $script:FirstInitBackup) { [System.IO.File]::WriteAllBytes($path, $script:FirstInitBackup) $script:FirstInitBackup = $null Invoke-MkFat32 } } # 替换 AUTOEXEC.BAT 内容(cmd.elf 启动时逐行自动执行)并重建 SATA 镜像;原文件自动备份 function Set-DeshabAutoexec { param([Parameter(Mandatory)][string[]]$Lines) $path = Join-Path $script:SystemDir 'bin\AUTOEXEC.BAT' if ($null -eq $script:AutoexecBackup) { $script:AutoexecBackup = [System.IO.File]::ReadAllBytes($path) } [System.IO.File]::WriteAllText($path, (($Lines -join "`r`n") + "`r`n")) Invoke-MkFat32 } function Restore-DeshabAutoexec { $path = Join-Path $script:SystemDir 'bin\AUTOEXEC.BAT' if ($null -ne $script:AutoexecBackup) { [System.IO.File]::WriteAllBytes($path, $script:AutoexecBackup) $script:AutoexecBackup = $null Invoke-MkFat32 } } function Restore-DeshabScenario { Restore-DeshabFirstInit Restore-DeshabAutoexec } # ================================================================ # 结果与断言 # ================================================================ function New-TestCaseResult { param([Parameter(Mandatory)][string]$Case) return [pscustomobject]@{ Case = $Case Status = 'PASS' StartTime = Get-Date DurationSec = 0.0 LogPath = '' Checks = New-Object System.Collections.ArrayList Notes = New-Object System.Collections.ArrayList } } function Add-TestCheck { param( [Parameter(Mandatory)]$Result, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Status, # PASS / FAIL / MANUAL / SKIP [string]$Detail = '' ) [void]$Result.Checks.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail }) if ($Status -eq 'FAIL') { $Result.Status = 'FAIL' } } function Add-TestNote { param([Parameter(Mandatory)]$Result, [Parameter(Mandatory)][string]$Note) [void]$Result.Notes.Add($Note) } # 日志断言引擎:必须包含 / 禁止包含 / 按顺序包含 / 出现次数下限 function Assert-QemuLog { param( [Parameter(Mandatory)]$Result, [Parameter(Mandatory)][string]$LogText, [string[]]$MustContain = @(), [string[]]$MustNotContain = @(), [string[]]$Ordered = @(), [string]$CountPattern = '', [int]$MinCount = 0 ) foreach ($p in $MustContain) { if ($LogText.Contains($p)) { Add-TestCheck $Result "锚点: $p" 'PASS' } else { Add-TestCheck $Result "锚点: $p" 'FAIL' 'pattern not found in serial log' } } foreach ($p in $MustNotContain) { if ($LogText.Contains($p)) { Add-TestCheck $Result "禁止: $p" 'FAIL' 'forbidden pattern found in serial log' } else { Add-TestCheck $Result "禁止: $p" 'PASS' } } if ($Ordered.Count -gt 0) { $pos = 0 $failAt = '' foreach ($p in $Ordered) { $idx = $LogText.IndexOf($p, $pos) if ($idx -lt 0) { $failAt = $p; break } $pos = $idx + $p.Length } if ($failAt -eq '') { Add-TestCheck $Result ("顺序链({0} 锚点)" -f $Ordered.Count) 'PASS' ($Ordered -join ' -> ') } else { Add-TestCheck $Result ("顺序链({0} 锚点)" -f $Ordered.Count) 'FAIL' "missing/out-of-order: $failAt" } } if ($CountPattern -ne '' -and $MinCount -gt 0) { $count = 0 $pos = 0 while ($true) { $idx = $LogText.IndexOf($CountPattern, $pos) if ($idx -lt 0) { break } $count++ $pos = $idx + $CountPattern.Length } if ($count -ge $MinCount) { Add-TestCheck $Result "计数: '$CountPattern' x$count (>= $MinCount)" 'PASS' } else { Add-TestCheck $Result "计数: '$CountPattern' x$count (>= $MinCount)" 'FAIL' "only $count occurrences" } } } # 输出用例结果;FAIL 时附带日志尾部上下文 function Write-TestCaseResult { param([Parameter(Mandatory)]$Result) $Result.DurationSec = [math]::Round(((Get-Date) - $Result.StartTime).TotalSeconds, 1) Write-Host '' Write-Host ("=== [{0}] {1} ({2}s) ===" -f $Result.Case, $Result.Status, $Result.DurationSec) foreach ($c in $Result.Checks) { $mark = '[PASS]' if ($c.Status -eq 'FAIL') { $mark = '[FAIL] ' } if ($c.Status -eq 'MANUAL') { $mark = '[MANUAL]' } if ($c.Status -eq 'SKIP') { $mark = '[SKIP] ' } Write-Host (" {0} {1}" -f $mark, $c.Name) if ($c.Detail -and $c.Status -ne 'PASS') { Write-Host (" -> {0}" -f $c.Detail) } } foreach ($n in $Result.Notes) { Write-Host " [note] $n" } if ($Result.Status -eq 'FAIL' -and $Result.LogPath -and (Test-Path $Result.LogPath)) { Write-Host ' --- serial log tail (25 lines) ---' Get-Content $Result.LogPath -Tail 25 | ForEach-Object { Write-Host " | $_" } } } # ================================================================ # FirstInit 设置向导自动填表(首启流程公共助手) # 账户卡片(3 字段) → 偏好页(5xEnter) → 网络页(3xEnter) → 完成 # 每步等串口锚点再发键;字段验证失败自动重试 3 次。 # ================================================================ function Complete-DeshabFirstInitWizard { param( [Parameter(Mandatory)]$Session, [Parameter(Mandatory)]$Result, [string]$Prefix = 'FirstInit' ) $hit = Wait-QemuLog -Session $Session -Patterns @('[FI] fadein done') -TimeoutSeconds 150 if (-not $hit) { Add-TestCheck $Result "${Prefix}: 账户卡片就绪" 'FAIL' 'missing [FI] fadein done'; return $false } $done = $false for ($attempt = 1; $attempt -le 3 -and -not $done; $attempt++) { Send-QemuText -Session $Session -Text 'testpc' -Enter Start-Sleep -Milliseconds 400 Send-QemuText -Session $Session -Text 'tester' -Enter Start-Sleep -Milliseconds 400 Send-QemuText -Session $Session -Text 'pass123' -Enter $hit = Wait-QemuLog -Session $Session -Patterns @('[FI] all fields done', '[FI] validation failed') -TimeoutSeconds 30 if ($hit -eq '[FI] all fields done') { $done = $true } else { Add-TestNote $Result "${Prefix}: 字段验证失败,重试第 $attempt 次" } } if (-not $done) { Add-TestCheck $Result "${Prefix}: 账户字段填写" 'FAIL' 'validation did not pass after 3 attempts'; return $false } Add-TestCheck $Result "${Prefix}: 账户字段填写" 'PASS' Start-Sleep -Milliseconds 600 Send-QemuKeys -Session $Session -Keys @('ret', 'ret', 'ret', 'ret', 'ret') -DelayMs 350 $hit = Wait-QemuLog -Session $Session -Patterns @('[FirstInit] entering network setup page') -TimeoutSeconds 40 if (-not $hit) { Add-TestCheck $Result "${Prefix}: 偏好页" 'FAIL' 'did not reach network setup page'; return $false } Add-TestCheck $Result "${Prefix}: 偏好页(5xEnter)" 'PASS' Start-Sleep -Milliseconds 400 Send-QemuKeys -Session $Session -Keys @('ret', 'ret', 'ret') -DelayMs 350 $hit = Wait-QemuLog -Session $Session -Patterns @('[FirstInit] user setup finished') -TimeoutSeconds 40 if (-not $hit) { Add-TestCheck $Result "${Prefix}: 网络页" 'FAIL' 'setup did not finish'; return $false } Add-TestCheck $Result "${Prefix}: 网络页(3xEnter)" 'PASS' return $true } # ================================================================ # 极简 HTTP 测试服务器(network-e2e 用) # slirp 网关 10.0.2.2 → 主机 127.0.0.1;guest curl 经 slirp 访问主机服务。 # 后台 Job 接收连接,把每个请求首行写入 $LogPath(主机侧端到端证据), # 回复固定 HTTP/1.0 200 + 标记体后关闭。 # ================================================================ function Start-TestHttpServer { param( [int]$Port = 8000, [Parameter(Mandatory)][string]$LogPath, [string]$Marker = 'DESHAB-NET-E2E-MARKER' ) if (Test-Path $LogPath) { Remove-Item $LogPath -Force } $job = Start-Job -ScriptBlock { param($Port, $LogPath, $Marker) $listener = $null try { $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port) $listener.Start() $body = "

$Marker

" $deadline = (Get-Date).AddMinutes(30) while ((Get-Date) -lt $deadline) { if (-not $listener.Pending()) { Start-Sleep -Milliseconds 200; continue } $client = $listener.AcceptTcpClient() try { $stream = $client.GetStream() $stream.ReadTimeout = 5000 $buf = New-Object byte[] 4096 $req = '' while ($stream.DataAvailable -or $req -eq '') { $n = 0 try { $n = $stream.Read($buf, 0, $buf.Length) } catch { break } if ($n -le 0) { break } $req += [System.Text.Encoding]::ASCII.GetString($buf, 0, $n) if ($req.Contains("`r`n`r`n")) { break } } $firstLine = ($req -split "`r`n")[0] [System.IO.File]::AppendAllText($LogPath, "$(Get-Date -Format 'HH:mm:ss') $firstLine`r`n") $resp = "HTTP/1.0 200 OK`r`nContent-Type: text/html`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n$body" $bytes = [System.Text.Encoding]::ASCII.GetBytes($resp) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush() } finally { $client.Close() } } } finally { if ($listener) { $listener.Stop() } } } -ArgumentList $Port, $LogPath, $Marker Start-Sleep -Milliseconds 800 if ($job.State -ne 'Running') { $err = Receive-Job $job 2>&1 | Out-String throw "http test server failed to start on port ${Port}: $err" } Write-Host "[http] test server listening on 127.0.0.1:$Port (log=$LogPath)" return $job } function Stop-TestHttpServer { param($Job) if ($Job) { Stop-Job $Job -ErrorAction SilentlyContinue Remove-Job $Job -Force -ErrorAction SilentlyContinue } }