Files
Deshab-OS/tests/qemu/lib/QemuTest.ps1
T
2026-07-31 13:53:49 +08:00

705 lines
26 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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
}
}