This commit is contained in:
xyou
2026-07-31 13:53:49 +08:00
parent 5ea84fac97
commit f52157b7af
241 changed files with 26843 additions and 8155 deletions
+704
View File
@@ -0,0 +1,704 @@
#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(格式 "<first>`n<dev_mode>",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 = "<html><body><h1>$Marker</h1></body></html>"
$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
}
}