2026-08-04 18:08:08 +08:00
|
|
|
|
#requires -Version 5.1
|
2026-07-31 13:53:49 +08:00
|
|
|
|
<#
|
|
|
|
|
|
.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'
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# 并发隔离布局(另一代理并发跑 QEMU/build,共享路径一律只读,写入全进私有区):
|
|
|
|
|
|
# .build_tmp\tests\img\ 私有镜像副本(deshab/nvme/usb,QEMU 只挂副本)
|
|
|
|
|
|
# .build_tmp\tests\SYSTEM\ SYSTEM 镜像树(robocopy /MIR;场景文件只翻这里)
|
|
|
|
|
|
# .build_tmp\tests\CODE\...\deaicup.exe mkfat32 引用的单文件副本
|
|
|
|
|
|
# .build_tmp\tests\fat32gen\ mkfat32.ps1 副本 + 私有 SATA 镜像输出
|
|
|
|
|
|
$script:SuiteImgDir = Join-Path $script:TestWorkDir 'img'
|
|
|
|
|
|
$script:MkFat32Dir = Join-Path $script:TestWorkDir 'fat32gen'
|
|
|
|
|
|
$script:MirrorSystem = Join-Path $script:TestWorkDir 'SYSTEM'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
$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
|
2026-08-04 18:08:08 +08:00
|
|
|
|
New-Item -ItemType Directory -Force -Path $script:SuiteImgDir | Out-Null
|
|
|
|
|
|
New-Item -ItemType Directory -Force -Path $script:MkFat32Dir | Out-Null
|
2026-07-31 13:53:49 +08:00
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$imgSrc = Join-Path $script:IsoDir 'deshab.img'
|
|
|
|
|
|
$nvmeSrc = Join-Path $script:BuildTmp 'nvme_test.img'
|
|
|
|
|
|
$usbSrc = Join-Path $script:BuildTmp 'usb_test.img'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
if ($Build -or -not (Test-Path $imgSrc)) {
|
2026-07-31 13:53:49 +08:00
|
|
|
|
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" }
|
|
|
|
|
|
}
|
2026-08-04 18:08:08 +08:00
|
|
|
|
if (-not (Test-Path $imgSrc)) { throw "image missing: $imgSrc (run with -Build)" }
|
|
|
|
|
|
|
|
|
|
|
|
# --- 并发隔离:SYSTEM 镜像树 + 私有 SATA 生成器 + 私有镜像副本 ---
|
|
|
|
|
|
Sync-SuiteTree
|
|
|
|
|
|
Invoke-MkFat32
|
|
|
|
|
|
$sata = Join-Path $script:MkFat32Dir 'sata_fat32_dsk.img'
|
|
|
|
|
|
if (-not (Test-Path $sata)) { throw "private sata image not generated: $sata" }
|
2026-07-31 13:53:49 +08:00
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$img = Copy-SuiteImage -Source $imgSrc -FileName 'deshab.img' -Mandatory
|
|
|
|
|
|
$nvme = Copy-SuiteImage -Source $nvmeSrc -FileName 'nvme_test.img'
|
|
|
|
|
|
$usb = Copy-SuiteImage -Source $usbSrc -FileName 'usb_test.img'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
|
|
|
|
|
|
$script:Config = [pscustomobject]@{
|
|
|
|
|
|
QemuExe = $qemu
|
|
|
|
|
|
Ovmf = $ovmf
|
|
|
|
|
|
Img = $img
|
|
|
|
|
|
SataImg = $sata
|
2026-08-04 18:08:08 +08:00
|
|
|
|
NvmeImg = $nvme
|
|
|
|
|
|
UsbImg = $usb
|
2026-07-31 13:53:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
Write-Host "[init] QEMU: $qemu"
|
|
|
|
|
|
Write-Host "[init] OVMF: $ovmf"
|
2026-08-04 18:08:08 +08:00
|
|
|
|
Write-Host "[init] IMG: $img (private copy)"
|
2026-07-31 13:53:49 +08:00
|
|
|
|
return $script:Config
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# ================================================================
|
|
|
|
|
|
# 并发隔离:共享文件只读复制到套件私有区
|
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# share-tolerant 复制:源可能被并发代理的 QEMU/打包器短暂占用,重试至多 60s。
|
|
|
|
|
|
# 目标不变(mtime+size 相同)时跳过,避免每用例重复拷 768MB。
|
|
|
|
|
|
function Copy-SuiteImage {
|
|
|
|
|
|
param(
|
|
|
|
|
|
[Parameter(Mandatory)][string]$Source,
|
|
|
|
|
|
[Parameter(Mandatory)][string]$FileName,
|
|
|
|
|
|
[switch]$Mandatory
|
|
|
|
|
|
)
|
|
|
|
|
|
if (-not (Test-Path $Source)) {
|
|
|
|
|
|
if ($Mandatory) { throw "image missing: $Source (run with -Build)" }
|
|
|
|
|
|
return $null
|
|
|
|
|
|
}
|
|
|
|
|
|
$dest = Join-Path $script:SuiteImgDir $FileName
|
|
|
|
|
|
$srcItem = Get-Item $Source
|
|
|
|
|
|
if ((Test-Path $dest)) {
|
|
|
|
|
|
$dstItem = Get-Item $dest
|
|
|
|
|
|
if ($dstItem.Length -eq $srcItem.Length -and $dstItem.LastWriteTimeUtc -eq $srcItem.LastWriteTimeUtc) {
|
|
|
|
|
|
return $dest
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$deadline = (Get-Date).AddSeconds(60)
|
|
|
|
|
|
while ($true) {
|
|
|
|
|
|
$in = $null; $out = $null
|
|
|
|
|
|
try {
|
|
|
|
|
|
$in = [System.IO.File]::Open($Source, 'Open', 'Read', [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)
|
|
|
|
|
|
$out = [System.IO.File]::Open($dest, 'Create', 'Write', [System.IO.FileShare]::None)
|
|
|
|
|
|
$in.CopyTo($out, 1048576)
|
|
|
|
|
|
$out.Close(); $in.Close()
|
|
|
|
|
|
[System.IO.File]::SetLastWriteTimeUtc($dest, $srcItem.LastWriteTimeUtc)
|
|
|
|
|
|
Write-Host "[init] private copy: $FileName ($([math]::Round($srcItem.Length/1MB))MB)"
|
|
|
|
|
|
return $dest
|
|
|
|
|
|
} catch [System.IO.IOException] {
|
|
|
|
|
|
if ($out) { try { $out.Close() } catch {} }
|
|
|
|
|
|
if ($in) { try { $in.Close() } catch {} }
|
|
|
|
|
|
if ((Get-Date) -gt $deadline) {
|
|
|
|
|
|
throw "cannot copy $Source (locked by concurrent process for >60s)"
|
|
|
|
|
|
}
|
|
|
|
|
|
Start-Sleep -Seconds 2
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if ($out) { try { $out.Close() } catch {} }
|
|
|
|
|
|
if ($in) { try { $in.Close() } catch {} }
|
|
|
|
|
|
throw
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# SYSTEM 镜像树 + deaicup.exe 单文件 + mkfat32.ps1 生成器副本。
|
|
|
|
|
|
# 场景翻转(firstInit/AUTOEXEC)只作用于镜像树,共享工作树绝不写入。
|
|
|
|
|
|
function Sync-SuiteTree {
|
|
|
|
|
|
& robocopy $script:SystemDir $script:MirrorSystem /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
|
|
|
|
|
if ($LASTEXITCODE -ge 8) { throw "robocopy SYSTEM mirror failed (rc=$LASTEXITCODE)" }
|
|
|
|
|
|
|
|
|
|
|
|
$deaSrc = Join-Path $script:RepoRoot 'CODE\Deaicup Software\pe\target\x86_64-pc-windows-msvc\release\deaicup.exe'
|
|
|
|
|
|
if (Test-Path $deaSrc) {
|
|
|
|
|
|
$deaDst = Join-Path $script:TestWorkDir 'CODE\Deaicup Software\pe\target\x86_64-pc-windows-msvc\release\deaicup.exe'
|
|
|
|
|
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $deaDst) | Out-Null
|
|
|
|
|
|
$srcItem = Get-Item $deaSrc
|
|
|
|
|
|
$need = $true
|
|
|
|
|
|
if (Test-Path $deaDst) {
|
|
|
|
|
|
$dstItem = Get-Item $deaDst
|
|
|
|
|
|
if ($dstItem.Length -eq $srcItem.Length -and $dstItem.LastWriteTimeUtc -eq $srcItem.LastWriteTimeUtc) { $need = $false }
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($need) { Copy-Item $deaSrc $deaDst -Force }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$mkSrc = Join-Path $script:BuildTmp 'mkfat32.ps1'
|
|
|
|
|
|
if (-not (Test-Path $mkSrc)) { throw "mkfat32.ps1 not found: $mkSrc (run build.ps1 once)" }
|
|
|
|
|
|
Copy-Item $mkSrc (Join-Path $script:MkFat32Dir 'mkfat32.ps1') -Force
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 13:53:49 +08:00
|
|
|
|
# ================================================================
|
|
|
|
|
|
# 进程管理
|
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# 清理本套件历史会话残留的 QEMU(Start-QemuSession 以 -name deshabtest-... 标记)。
|
|
|
|
|
|
# 只按 deshabtest- 标记精确匹配,绝不动其他代理/人工启动的 QEMU(并发隔离要求:
|
|
|
|
|
|
# 另一代理可能正挂着本仓库镜像跑调试,全局或按路径匹配都会误杀)。
|
2026-07-31 13:53:49 +08:00
|
|
|
|
function Stop-ResidualQemu {
|
|
|
|
|
|
$procs = Get-CimInstance Win32_Process -Filter "Name='qemu-system-x86_64.exe'" -ErrorAction SilentlyContinue
|
|
|
|
|
|
foreach ($p in $procs) {
|
2026-08-04 18:08:08 +08:00
|
|
|
|
if ($p.CommandLine -and $p.CommandLine.Contains('deshabtest-')) {
|
|
|
|
|
|
Write-Host "[qemu] killing residual suite qemu pid=$($p.ProcessId)"
|
2026-07-31 13:53:49 +08:00
|
|
|
|
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# monitor 端口:45501-45508 按用例分配,deaicup-e2e 固定 45454
|
|
|
|
|
|
# (避开人工调试 4444 与其他套件 456xx/457xx)。
|
|
|
|
|
|
# -Preferred 传入用例固定端口;被占用(如 TIME_WAIT)时扫描段内其余端口,
|
|
|
|
|
|
# 最后兜底 45509-45599。段内端口被他人占用时只换端口,绝不杀占用进程。
|
2026-07-31 13:53:49 +08:00
|
|
|
|
function Get-FreeMonitorPort {
|
2026-08-04 18:08:08 +08:00
|
|
|
|
param([int]$Preferred = 0)
|
|
|
|
|
|
$candidates = New-Object System.Collections.ArrayList
|
|
|
|
|
|
if (($Preferred -ge 45501 -and $Preferred -le 45508) -or $Preferred -eq 45454) { [void]$candidates.Add($Preferred) }
|
|
|
|
|
|
foreach ($p in 45501..45508) { if ($p -ne $Preferred) { [void]$candidates.Add($p) } }
|
|
|
|
|
|
foreach ($p in 45509..45599) { [void]$candidates.Add($p) }
|
|
|
|
|
|
foreach ($port in $candidates) {
|
2026-07-31 13:53:49 +08:00
|
|
|
|
$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,
|
2026-08-04 18:08:08 +08:00
|
|
|
|
[int]$MonitorPort = 0,
|
|
|
|
|
|
[switch]$NoSnapshot,
|
2026-07-31 13:53:49 +08:00
|
|
|
|
[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)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# -snapshot 默认开:镜像只读 + 写入落临时 overlay,避免镜像写锁与并发代理冲突。
|
|
|
|
|
|
if (-not $NoSnapshot) { $qargs += '-snapshot' }
|
|
|
|
|
|
|
2026-07-31 13:53:49 +08:00
|
|
|
|
$port = 0
|
|
|
|
|
|
if (-not $NoMonitor) {
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$port = Get-FreeMonitorPort -Preferred $MonitorPort
|
2026-07-31 13:53:49 +08:00
|
|
|
|
$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')
|
|
|
|
|
|
}
|
2026-08-04 18:08:08 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
# 已知坑3:不显式禁网时 QEMU 会自动补一块默认 e1000 网卡(q35 默认型号),
|
|
|
|
|
|
# -NoNet 场景必须用 -nic none 把默认网络整个关掉。
|
|
|
|
|
|
$qargs += @('-nic', 'none')
|
2026-07-31 13:53:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
foreach ($x in $ExtraArgs) { $qargs += $x }
|
|
|
|
|
|
$qargs += @('-boot', 'menu=on')
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# 已知坑1:Start-Process 下 -serial file: 不生效;用 cmd 批处理重定向 stdio。
|
|
|
|
|
|
# 已知坑2:-snapshot 会在 %TEMP% 创建临时 overlay,Windows 用户名含非 ASCII
|
|
|
|
|
|
# (如中文)时 QEMU 创建失败;把 TEMP/TMP 重定向到仓库内 ASCII 目录。
|
|
|
|
|
|
$qemuTmp = Join-Path $script:BuildTmp 'qemu-tmp'
|
|
|
|
|
|
New-Item -ItemType Directory -Force -Path $qemuTmp | Out-Null
|
2026-07-31 13:53:49 +08:00
|
|
|
|
$cmdline = '"' + $cfg.QemuExe + '" ' + ($qargs -join ' ')
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$batContent = "@echo off`r`nset `"TEMP=$qemuTmp`"`r`nset `"TMP=$qemuTmp`"`r`n$cmdline > `"$log`" 2>&1`r`n"
|
2026-07-31 13:53:49 +08:00
|
|
|
|
[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
|
|
|
|
|
|
}
|
2026-08-04 18:08:08 +08:00
|
|
|
|
|
|
|
|
|
|
# 早夭自愈:WHPX 分区被强杀后偶发重建异常,guest 启动即三重故障
|
|
|
|
|
|
# (实测日志仅一行 "Ignoring request for interrupt vector 0")。
|
|
|
|
|
|
# 启动后观察 3s:进程已死且日志近乎为空 → 同一 bat 重开一次
|
|
|
|
|
|
# (tag/monitor 端口不变,旧进程已死无冲突;bat 重定向会重建日志)。
|
|
|
|
|
|
Start-Sleep -Seconds 3
|
|
|
|
|
|
if (Test-QemuSessionExited $session) {
|
|
|
|
|
|
$logSize = 0
|
|
|
|
|
|
if (Test-Path $log) { $logSize = (Get-Item $log).Length }
|
|
|
|
|
|
if ($logSize -lt 4096) {
|
|
|
|
|
|
Write-Host "[qemu] session '$Name' died at launch (log ${logSize}B, WHPX startup flake), relaunching once"
|
|
|
|
|
|
Start-Sleep -Milliseconds 800
|
|
|
|
|
|
Start-Process -FilePath $bat -WindowStyle Hidden
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Write-Host "[qemu] session '$Name' exited early (log ${logSize}B)"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 13:53:49 +08:00
|
|
|
|
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 镜像重建)
|
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# 私有 SATA 镜像生成:运行 fat32gen\ 下的 mkfat32.ps1 副本。
|
|
|
|
|
|
# 副本的 PSScriptRoot=fat32gen → 输出 fat32gen\sata_fat32_dsk.img(私有),
|
|
|
|
|
|
# 输入 tests\SYSTEM 镜像树 + tests\CODE\...\deaicup.exe,不读写任何共享路径。
|
2026-07-31 13:53:49 +08:00
|
|
|
|
function Invoke-MkFat32 {
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$mk = Join-Path $script:MkFat32Dir 'mkfat32.ps1'
|
|
|
|
|
|
if (-not (Test-Path $mk)) { throw "mkfat32.ps1 copy not found: $mk (Initialize-QemuTest runs Sync-SuiteTree)" }
|
2026-07-31 13:53:49 +08:00
|
|
|
|
& powershell -ExecutionPolicy Bypass -File $mk | Out-Null
|
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw "mkfat32.ps1 failed with exit code $LASTEXITCODE" }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# 写镜像树 firstInit.txt(格式 "<first>`n<dev_mode>",LF)并重建私有 SATA 镜像;原内容自动备份。
|
|
|
|
|
|
# 只写 .build_tmp\tests\SYSTEM 镜像树,共享 SYSTEM 工作树保持不变(并发代理可能正在翻转它)。
|
2026-07-31 13:53:49 +08:00
|
|
|
|
function Set-DeshabFirstInit {
|
|
|
|
|
|
param(
|
|
|
|
|
|
[Parameter(Mandatory)][int]$First,
|
|
|
|
|
|
[Parameter(Mandatory)][int]$DevMode
|
|
|
|
|
|
)
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$path = Join-Path $script:MirrorSystem 'system\user\use\firstInit.txt'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
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 {
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$path = Join-Path $script:MirrorSystem 'system\user\use\firstInit.txt'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
if ($null -ne $script:FirstInitBackup) {
|
|
|
|
|
|
[System.IO.File]::WriteAllBytes($path, $script:FirstInitBackup)
|
|
|
|
|
|
$script:FirstInitBackup = $null
|
|
|
|
|
|
Invoke-MkFat32
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 18:08:08 +08:00
|
|
|
|
# 替换镜像树 AUTOEXEC.BAT 内容(cmd.elf 启动时逐行自动执行)并重建私有 SATA 镜像;原内容自动备份
|
2026-07-31 13:53:49 +08:00
|
|
|
|
function Set-DeshabAutoexec {
|
|
|
|
|
|
param([Parameter(Mandatory)][string[]]$Lines)
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$path = Join-Path $script:MirrorSystem 'bin\AUTOEXEC.BAT'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
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 {
|
2026-08-04 18:08:08 +08:00
|
|
|
|
$path = Join-Path $script:MirrorSystem 'bin\AUTOEXEC.BAT'
|
2026-07-31 13:53:49 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|