From 696c1b4bb285b1205458c23886a726fab9b03e3a Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 5 Jul 2026 21:30:11 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/okvm-test/.gitignore | 4 + test/okvm-test/README.md | 187 ++ test/okvm-test/agent/build-windows.sh | 12 + test/okvm-test/agent/go.mod | 5 + test/okvm-test/agent/go.sum | 2 + test/okvm-test/agent/main.go | 1202 +++++++++++++ test/okvm-test/okvm_media.py | 79 + test/okvm-test/okvm_report.py | 671 +++++++ test/okvm-test/okvm_testctl.py | 2319 +++++++++++++++++++++++++ test/okvm-test/requirements.txt | 5 + 10 files changed, 4486 insertions(+) create mode 100644 test/okvm-test/.gitignore create mode 100644 test/okvm-test/README.md create mode 100755 test/okvm-test/agent/build-windows.sh create mode 100644 test/okvm-test/agent/go.mod create mode 100644 test/okvm-test/agent/go.sum create mode 100644 test/okvm-test/agent/main.go create mode 100644 test/okvm-test/okvm_media.py create mode 100644 test/okvm-test/okvm_report.py create mode 100755 test/okvm-test/okvm_testctl.py create mode 100644 test/okvm-test/requirements.txt diff --git a/test/okvm-test/.gitignore b/test/okvm-test/.gitignore new file mode 100644 index 00000000..ab8e77fa --- /dev/null +++ b/test/okvm-test/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +reports/ +bin/*.exe diff --git a/test/okvm-test/README.md b/test/okvm-test/README.md new file mode 100644 index 00000000..92d47e2b --- /dev/null +++ b/test/okvm-test/README.md @@ -0,0 +1,187 @@ +# One-KVM 自动化产测工具 + +工具由两部分组成: + +- `okvm_testctl.py`:Windows 本地控制端。负责 SSH 操作、One-KVM HTTP API 调用、MJPEG/WebRTC 客户端、HID 检测、MSD 检测、ATX API 检测和报告输出。`run` 子命令只支持原生 Windows,不支持 WSL/Linux。 +- `agent/`:Windows 被控机配套程序。使用 Go 编写,编译为单文件 `.exe`。 + +## 编译 Windows 配套程序 + +在 Windows PowerShell 安装 Go 后执行: + +```powershell +cd test/okvm-test/agent +New-Item -ItemType Directory -Force ..\bin +go build -o ..\bin\okvm-win-agent-amd64.exe . +``` + +编译产物输出到: + +```text +test/okvm-test/bin/okvm-win-agent-amd64.exe +``` + +将该 exe 放到被控 Windows 机器上,在开始产测前运行: + +```powershell +.\okvm-win-agent-amd64.exe -listen 0.0.0.0:8765 +``` + +Windows 配套程序会监听所有网卡地址,本地控制端通过 `--agent-host ` 主动连接它。配套程序默认隐藏测试窗口,只有视频延迟、HID 等需要采集画面/输入时才显示。 + +## 安装本地控制端依赖 + +Windows 控制端使用 PowerShell: + +```powershell +cd test/okvm-test +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python -m playwright install chrome +``` + +控制端固定使用 Google Chrome (`chrome`) 跑 WebRTC/H.265 测试,以便使用 Windows 核显/系统解码。所有 Chrome 启动都会加入 `--enable-features=WebRtcAllowH265Receive` 和 `--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled`。 + +## 针对当前测试机运行 + +PowerShell: + +```powershell +cd test/okvm-test +$env:OKVM_SSH_PASSWORD="1234" +python okvm_testctl.py run ` + --target 192.168.137.67 ` + --ssh-user root ` + --ssh-password-prompt ` + --reset ` + --agent-host ` + --agent-port 8765 +``` + +默认流程: + +1. 通过 SSH 停止 `one-kvm`。 +2. 备份 `/etc/one-kvm/one-kvm.db`。 +3. 删除当前数据库,实现强制重置。 +4. 启动 `one-kvm`。 +5. 采集控制端到目标机的 TCP connect 和 HTTP `/health` 往返延迟,调用初始化接口创建测试账号,登录后发现设备并应用测试配置。 +6. 启用 OTG MSD 后,默认把本仓库的 Ventoy 资源同步到目标机 `/etc/one-kvm/ventoy`,并重启一次 `one-kvm` 让资源初始化生效。 +7. 执行视频输入/输出、每个视频输入的 MJPEG 端到端画面延迟、HDMI 画面确认、颜色偏差、HID、MSD、ATX API 和性能检测。 +8. 输出中文完整报告 `reports//report-YYYYMMDD-HHMMSS.md` 和面向用户的迷你摘要 `reports//user-report-YYYYMMDD-HHMMSS.md`,截图、日志和采集帧保存在 `reports//evidence/`。 + +终端结果默认使用颜色区分:绿色为 `PASS`,黄色为 `WARN/SKIP`,红色为 `FAIL`。可通过 `--no-color` 或 `NO_COLOR=1` 关闭颜色。 + +## 视频测试规则 + +控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入: + +- USB2.0 采集卡:测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 +- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 +- CSI/MIPI:只测试一套 `1080p60 NV12`,不做输入格式切换。 + +每个输入配置都会跑三种输出: + +- MJPEG/HTTP +- H.264 WebRTC +- H.265 WebRTC + +默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。 + +MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps ` 覆盖。 + +如果浏览器或编码器不支持 H.265,会记录为 `SKIP`。 + +默认情况下,视频链路只要实际 fps 大于 0 就视为功能成功;报告保留实际 fps、RTT 和 jitter。需要把低帧率作为失败时,加 `--strict-performance`。 + +## HDMI 采集与视频延迟测试 + +连接 Windows agent 后,控制端会让 Windows 配套程序打开全屏纯色窗口,然后通过 One-KVM 的 MJPEG/HTTP 或浏览器 WebRTC 解码画面抓帧检测: + +- `hdmi_identity`:确认 HDMI 采集画面确实跟随被控 Windows 画面变化,至少验证红、绿、蓝三种高饱和颜色。 +- `hdmi_color_range`:采样画面中心区域,输出期望 RGB、实测 RGB 均值、标准差、平均误差和最大通道误差。 +- `video_latency_<输入>_mjpeg` / `video_latency_<输入>_h264` / `video_latency_<输入>_h265`:每个被选中的视频输入 case 都会分别执行 MJPEG、H.264、H.265 延迟测试。Windows agent 延迟切换纯色,Windows 控制端从 MJPEG 帧或 Chrome 解码后的 WebRTC video 帧中检测首次颜色变化,输出端到端视觉延迟 p50、p95、max。延迟测试不保存命中帧截图。 + +纯色采样不会再用第一帧直接判定;控制端会在超时时间内持续拉取 MJPEG,等待目标颜色出现。若始终未匹配,会保存最佳接近帧,并在 Markdown 报告中写出实测 RGB、未命中数量和采样帧数。 + +默认颜色误差阈值: + +- 平均 RGB 误差 `<=30`:`PASS` +- 平均 RGB 误差 `<=60`:`WARN` +- 平均 RGB 误差 `>60`:`FAIL` + +默认视频视觉延迟阈值只用于基本功能判定: + +- p95 `<=3000ms`:`PASS` +- p95 `>3000ms` 或未检测到目标颜色:`FAIL` + +相关开关: + +- `--no-hdmi-tests`:跳过 HDMI 画面、颜色和视频视觉延迟测试。 +- `--hdmi-capture-timeout <秒>`:单个纯色采样等待目标颜色的最长时间,默认 8 秒。 +- `--hdmi-match-frames <帧数>`:目标颜色需要连续匹配的帧数,默认 1;静态 MJPEG 场景建议保持默认。 +- `--hdmi-color-warn ` / `--hdmi-color-fail `:调整颜色偏差阈值。 +- `--video-latency-trials <次数>`:调整切色延迟测试次数,默认 5 次。旧名 `--hdmi-latency-trials` 仍兼容。 +- `--video-latency-fail-ms `:调整基本功能阈值,默认 3000ms。旧名 `--hdmi-latency-fail-ms` 仍兼容。 + +WebRTC 性能表仍保留连接 RTT/jitter;真实画面级延迟会额外按 MJPEG、H.264、H.265 逐输入输出。 + +## 网页截图证据 + +默认保留关键过程网页截图到 `reports//evidence/screenshots/`: + +- 登录后控制台首页。 +- MJPEG 模式网页截图。 +- H.264 WebRTC 模式网页截图。 +- H.265 WebRTC 尝试网页截图。 +- HID/MSD/ATX 测试后的控制台状态。 + +视频模式截图会等待页面脱离“等待首帧/连接中”等过渡状态后再保存;H.265 不支持等真实失败页面会直接保留作为证据。等待时间可用 `--screenshot-wait-ms ` 调整,默认 6000ms。 + +如需跳过网页截图,可加 `--no-screenshots`。 + +## HID 测试 + +HID 测试依赖 Windows agent。流程如下: + +1. `/hid/status` 确认 HID 后端可用。 +2. 键盘矩阵覆盖全部字母、数字、F1-F12,以及不影响系统运行的 Ctrl/Shift + 功能键组合。 +3. 鼠标矩阵覆盖绝对坐标移动和相对移动。 +4. 键盘鼠标矩阵完成后,使用 F8 做 HID 延迟采样,输出 p50、p95、max。 + +HID 延迟默认 5 次采样,可通过 `--hid-latency-trials <次数>` 调整。默认 p95 `<=80ms` 为 `PASS`,`<=200ms` 为 `WARN`,更高为 `FAIL`;阈值可用 `--hid-latency-warn-ms ` 和 `--hid-latency-fail-ms ` 调整。 + +## MSD 测试 + +MSD 测试依赖 OTG。流程如下: + +1. 确认目标机 `/etc/one-kvm/ventoy` 存在 `boot.img`、`core.img`、`ventoy.disk.img`。 +2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。 +3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。 +4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。 +5. 通过 `/api/msd/connect {"mode":"drive"}` 连接到 Windows。 +6. Windows agent 等待新盘符出现。 +7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256,同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。 +8. 控制端断开 MSD,Windows agent 确认盘符消失。 + +相关开关: + +- `--ventoy-resources-dir `:指定本地 Ventoy 资源目录。 +- `--no-ventoy-sync`:不向目标机同步 Ventoy 资源。 +- `--no-msd-restart-after-enable`:启用 MSD 后不重启 `one-kvm`。 + +## ATX 测试 + +默认不执行真实电源按键动作,因为当前测试环境不会连接真实 ATX 设备。 + +默认只验证: + +- `/api/atx/status` +- `/api/config/atx` +- `/api/atx/wol` + +## 注意事项 + +- `--reset` 会删除 One-KVM 当前 SQLite 数据库,虽然会先备份,但这是破坏性操作。 +- 旧数据库不会自动恢复,备份保存在目标机 `/etc/one-kvm/test-backups//`。 +- 编译出的 `.exe` 和测试报告默认被 `.gitignore` 忽略。 diff --git a/test/okvm-test/agent/build-windows.sh b/test/okvm-test/agent/build-windows.sh new file mode 100755 index 00000000..fa4a0782 --- /dev/null +++ b/test/okvm-test/agent/build-windows.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +arch="${GOARCH:-amd64}" +out="../bin/okvm-win-agent-${arch}.exe" + +GOOS=windows GOARCH="$arch" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "$out" . + +echo "Built $out" diff --git a/test/okvm-test/agent/go.mod b/test/okvm-test/agent/go.mod new file mode 100644 index 00000000..cb26cc13 --- /dev/null +++ b/test/okvm-test/agent/go.mod @@ -0,0 +1,5 @@ +module okvm-win-agent + +go 1.22 + +require github.com/gorilla/websocket v1.5.3 diff --git a/test/okvm-test/agent/go.sum b/test/okvm-test/agent/go.sum new file mode 100644 index 00000000..25a9fc4b --- /dev/null +++ b/test/okvm-test/agent/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/test/okvm-test/agent/main.go b/test/okvm-test/agent/main.go new file mode 100644 index 00000000..f9f05127 --- /dev/null +++ b/test/okvm-test/agent/main.go @@ -0,0 +1,1202 @@ +//go:build windows + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "math/rand" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/gorilla/websocket" +) + +const ( + wsOverlapped = 0x00000000 + wsPopup = 0x80000000 + wsVisible = 0x10000000 + wsExTopmost = 0x00000008 + wsExToolWindow = 0x00000080 + + cwUseDefault = 0x80000000 + swShow = 5 + swHide = 0 + + wmDestroy = 0x0002 + wmPaint = 0x000F + wmClose = 0x0010 + wmKeyDown = 0x0100 + wmKeyUp = 0x0101 + wmChar = 0x0102 + wmSysKeyDown = 0x0104 + wmSysKeyUp = 0x0105 + wmTimer = 0x0113 + wmMouseMove = 0x0200 + wmLButtonDown = 0x0201 + wmLButtonUp = 0x0202 + wmRButtonDown = 0x0204 + wmRButtonUp = 0x0205 + wmMouseWheel = 0x020A + + vkLShift = 0xA0 + vkRShift = 0xA1 + vkLControl = 0xA2 + vkRControl = 0xA3 + vkLMenu = 0xA4 + vkRMenu = 0xA5 + vkLWin = 0x5B + vkRWin = 0x5C + + modLeftCtrl = 0x01 + modLeftShift = 0x02 + modLeftAlt = 0x04 + modLeftMeta = 0x08 + modRightCtrl = 0x10 + modRightShift = 0x20 + modRightAlt = 0x40 + modRightMeta = 0x80 + + wmApp = 0x8000 + wmAppInvalidate = wmApp + 1 + wmAppShow = wmApp + 2 + wmAppHide = wmApp + 3 + wmAppFocus = wmApp + 4 + wmAppDynamicStart = wmApp + 5 + wmAppDynamicStop = wmApp + 6 + + dynamicTimerID = 1 + + colorWindow = 5 + + driveUnknown = 0 + driveNoRootDir = 1 + driveRemovable = 2 + driveFixed = 3 + + genericRead = 0x80000000 + fileShareRead = 0x00000001 + fileShareWrite = 0x00000002 + openExisting = 3 + fileAttributeNormal = 0x00000080 + fileFlagNoBuffering = 0x20000000 + fileFlagSequentialScan = 0x08000000 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + imm32 = syscall.NewLazyDLL("imm32.dll") + + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procGetMessageW = user32.NewProc("GetMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procPostMessageW = user32.NewProc("PostMessageW") + procPostQuitMessage = user32.NewProc("PostQuitMessage") + procShowWindow = user32.NewProc("ShowWindow") + procSetForegroundWindow = user32.NewProc("SetForegroundWindow") + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procInvalidateRect = user32.NewProc("InvalidateRect") + procUpdateWindow = user32.NewProc("UpdateWindow") + procSetTimer = user32.NewProc("SetTimer") + procKillTimer = user32.NewProc("KillTimer") + procGetKeyState = user32.NewProc("GetKeyState") + procBeginPaint = user32.NewProc("BeginPaint") + procEndPaint = user32.NewProc("EndPaint") + procFillRect = user32.NewProc("FillRect") + procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush") + procDeleteObject = gdi32.NewProc("DeleteObject") + procImmAssociateContext = imm32.NewProc("ImmAssociateContext") + procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + procQueryPerformanceCount = kernel32.NewProc("QueryPerformanceCounter") + procQueryPerformanceFreq = kernel32.NewProc("QueryPerformanceFrequency") + procGetLogicalDrives = kernel32.NewProc("GetLogicalDrives") + procGetDriveTypeW = kernel32.NewProc("GetDriveTypeW") + procGetVolumeInformationW = kernel32.NewProc("GetVolumeInformationW") + procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW") + procGetDiskFreeSpaceW = kernel32.NewProc("GetDiskFreeSpaceW") + procCreateFileW = kernel32.NewProc("CreateFileW") + procReadFile = kernel32.NewProc("ReadFile") + procCloseHandle = kernel32.NewProc("CloseHandle") + procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware") +) + +type wndClassEx struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr +} + +type point struct { + X int32 + Y int32 +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point +} + +type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +type paintStruct struct { + Hdc uintptr + Erase int32 + RcPaint rect + Restore int32 + IncUpdate int32 + RgbReserved [32]byte +} + +type appState struct { + mu sync.Mutex + hwnd uintptr + bgColor uint32 + colorHex string + lastColorChangeQpc int64 + lastColorChangeUnix int64 + colorSequence int64 + dynamicActive bool + dynamicFPS int + dynamicFrame int64 + events []hidEvent +} + +type hidEvent struct { + Type string `json:"type"` + Code uint32 `json:"code,omitempty"` + Char string `json:"char,omitempty"` + Modifiers uint32 `json:"modifiers,omitempty"` + X int32 `json:"x,omitempty"` + Y int32 `json:"y,omitempty"` + WheelDelta int16 `json:"wheel_delta,omitempty"` + Qpc int64 `json:"qpc"` + UnixNano int64 `json:"unix_nano"` + Description string `json:"description,omitempty"` +} + +type command struct { + ID string `json:"id"` + Command string `json:"command"` + Payload json.RawMessage `json:"payload"` +} + +type response struct { + ID string `json:"id"` + OK bool `json:"ok"` + Type string `json:"type,omitempty"` + Payload interface{} `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +type driveInfo struct { + Letter string `json:"letter"` + Root string `json:"root"` + Type uint32 `json:"type"` + Label string `json:"label,omitempty"` + FileSystem string `json:"file_system,omitempty"` + TotalBytes uint64 `json:"total_bytes,omitempty"` + FreeBytes uint64 `json:"free_bytes,omitempty"` + Removable bool `json:"removable"` + WriteCapable bool `json:"write_capable"` + ObservedNano int64 `json:"observed_unix_nano"` +} + +var state = &appState{ + bgColor: 0x00101010, + colorHex: "#101010", + lastColorChangeQpc: qpcNow(), + lastColorChangeUnix: time.Now().UnixNano(), +} + +func main() { + listen := flag.String("listen", "0.0.0.0:8765", "listen address") + noWindow := flag.Bool("no-window", true, "start with the test window hidden; commands show it when needed") + flag.Parse() + + procSetProcessDPIAware.Call() + + uiReady := make(chan struct{}) + go runUI(uiReady, *noWindow) + <-uiReady + + log.Printf("Windows agent listening on ws://%s/agent", *listen) + if err := serveAgent(*listen); err != nil { + log.Fatal(err) + } +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +func serveAgent(listen string) error { + http.HandleFunc("/agent", handleAgentWebSocket) + return http.ListenAndServe(listen, nil) +} + +func handleAgentWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade failed: %v", err) + return + } + defer conn.Close() + + hello := map[string]interface{}{ + "type": "hello", + "hostname": hostname(), + "qpc_freq": qpcFreq(), + "drives": listDrives(), + "screen": screenInfo(), + "agent_time": time.Now().UnixNano(), + } + if err := conn.WriteJSON(hello); err != nil { + log.Printf("failed to send hello: %v", err) + return + } + + for { + var cmd command + if err := conn.ReadJSON(&cmd); err != nil { + log.Printf("agent client disconnected: %v", err) + return + } + resp := handleCommand(cmd) + if err := conn.WriteJSON(resp); err != nil { + log.Printf("failed to send command response: %v", err) + return + } + } +} + +func handleCommand(cmd command) response { + defer func() { + if r := recover(); r != nil { + log.Printf("panic while handling %s: %v", cmd.Command, r) + } + }() + + switch cmd.Command { + case "ping": + return ok(cmd, map[string]interface{}{ + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + }) + case "show": + var p struct { + Color string `json:"color"` + Full bool `json:"full"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.Color == "" { + p.Color = "#00ff00" + } + display := setColor(p.Color) + showWindow(true) + focusWindow() + return ok(cmd, display) + case "start_dynamic": + var p struct { + FPS int `json:"fps"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.FPS <= 0 { + p.FPS = 60 + } + display := startDynamic(p.FPS) + showWindow(true) + focusWindow() + return ok(cmd, display) + case "stop_dynamic": + stopDynamic() + return ok(cmd, currentDisplayState()) + case "schedule_color": + var p struct { + Color string `json:"color"` + DelayMS int `json:"delay_ms"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.Color == "" { + p.Color = "#00ff00" + } + if p.DelayMS < 0 { + p.DelayMS = 0 + } + delay := time.Duration(p.DelayMS) * time.Millisecond + scheduledUnix := time.Now().Add(delay).UnixNano() + color := normalizeColor(p.Color) + time.AfterFunc(delay, func() { + setColor(color) + showWindow(true) + }) + showWindow(true) + focusWindow() + return ok(cmd, map[string]interface{}{ + "color": color, + "delay_ms": p.DelayMS, + "scheduled_unix_nano": scheduledUnix, + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + }) + case "display_state": + return ok(cmd, currentDisplayState()) + case "hide": + stopDynamic() + showWindow(false) + return ok(cmd, map[string]interface{}{"hidden": true}) + case "begin_hid_capture": + clearEvents() + showWindow(true) + focusWindow() + return ok(cmd, map[string]interface{}{ + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + }) + case "get_hid_events": + return ok(cmd, map[string]interface{}{"events": snapshotEvents()}) + case "msd_snapshot": + return ok(cmd, map[string]interface{}{"drives": listDrives()}) + case "msd_wait_new": + var p struct { + Known []string `json:"known"` + TimeoutMS int `json:"timeout_ms"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.TimeoutMS <= 0 { + p.TimeoutMS = 30000 + } + drive, err := waitNewDrive(p.Known, time.Duration(p.TimeoutMS)*time.Millisecond) + if err != nil { + return fail(cmd, err) + } + return ok(cmd, drive) + case "msd_write_read": + var p struct { + Root string `json:"root"` + Filename string `json:"filename"` + SizeBytes int `json:"size_bytes"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.SizeBytes <= 0 { + p.SizeBytes = 1024 * 1024 + } + if p.Filename == "" { + p.Filename = "okvm-msd-test.bin" + } + result, err := writeReadVerify(p.Root, p.Filename, p.SizeBytes) + if err != nil { + return fail(cmd, err) + } + return ok(cmd, result) + case "msd_wait_removed": + var p struct { + Root string `json:"root"` + TimeoutMS int `json:"timeout_ms"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.TimeoutMS <= 0 { + p.TimeoutMS = 30000 + } + err := waitRemoved(p.Root, time.Duration(p.TimeoutMS)*time.Millisecond) + if err != nil { + return fail(cmd, err) + } + return ok(cmd, map[string]interface{}{"removed": true}) + default: + return fail(cmd, fmt.Errorf("unknown command: %s", cmd.Command)) + } +} + +func ok(cmd command, payload interface{}) response { + return response{ID: cmd.ID, OK: true, Type: cmd.Command, Payload: payload} +} + +func fail(cmd command, err error) response { + return response{ID: cmd.ID, OK: false, Type: cmd.Command, Error: err.Error()} +} + +func runUI(ready chan<- struct{}, hidden bool) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + instance := getModuleHandle() + className := syscall.StringToUTF16Ptr("OKVMWinAgentWindow") + wndProc := syscall.NewCallback(windowProc) + wc := wndClassEx{ + Size: uint32(unsafe.Sizeof(wndClassEx{})), + WndProc: wndProc, + Instance: instance, + Background: colorWindow + 1, + ClassName: className, + } + procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) + + width, height := screenSize() + style := uintptr(wsPopup) + if !hidden { + style |= wsVisible + } + hwnd, _, err := procCreateWindowExW.Call( + wsExTopmost|wsExToolWindow, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("One-KVM Test Agent"))), + style, + 0, + 0, + uintptr(width), + uintptr(height), + 0, + 0, + instance, + 0, + ) + if hwnd == 0 { + log.Fatalf("CreateWindowExW failed: %v", err) + } + state.mu.Lock() + state.hwnd = hwnd + state.mu.Unlock() + disableIME(hwnd) + if hidden { + showWindow(false) + } else { + showWindow(true) + } + close(ready) + + var m msg + for { + ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if int32(ret) <= 0 { + return + } + procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) + procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } +} + +func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { + msg := uint32(message) + switch msg { + case wmPaint: + var ps paintStruct + hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) + state.mu.Lock() + color := state.bgColor + state.mu.Unlock() + brush, _, _ := procCreateSolidBrush.Call(uintptr(color)) + r := rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())} + procFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), brush) + procDeleteObject.Call(brush) + procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) + return 0 + case wmTimer: + if wParam == dynamicTimerID { + advanceDynamicFrame(hwnd) + return 0 + } + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + case wmAppInvalidate: + invalidateWindowNow(hwnd) + return 0 + case wmAppShow: + procShowWindow.Call(hwnd, swShow) + invalidateWindowNow(hwnd) + return 0 + case wmAppHide: + procShowWindow.Call(hwnd, swHide) + return 0 + case wmAppFocus: + procSetForegroundWindow.Call(hwnd) + return 0 + case wmAppDynamicStart: + procKillTimer.Call(hwnd, dynamicTimerID) + interval := wParam + if interval == 0 { + interval = 16 + } + procSetTimer.Call(hwnd, dynamicTimerID, interval, 0) + invalidateWindowNow(hwnd) + return 0 + case wmAppDynamicStop: + procKillTimer.Call(hwnd, dynamicTimerID) + invalidateWindowNow(hwnd) + return 0 + case wmKeyDown: + appendEvent(hidEvent{Type: "key_down", Code: uint32(wParam), Modifiers: modifierState(), Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmKeyUp: + appendEvent(hidEvent{Type: "key_up", Code: uint32(wParam), Modifiers: modifierState(), Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmSysKeyDown: + appendEvent(hidEvent{Type: "key_down", Code: uint32(wParam), Modifiers: modifierState(), Description: "syskey", Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmSysKeyUp: + appendEvent(hidEvent{Type: "key_up", Code: uint32(wParam), Modifiers: modifierState(), Description: "syskey", Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmChar: + appendEvent(hidEvent{Type: "char", Code: uint32(wParam), Char: string(rune(wParam)), Modifiers: modifierState(), Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmMouseMove: + x, y := mouseXY(lParam) + appendEvent(hidEvent{Type: "mouse_move", X: x, Y: y, Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmLButtonDown, wmLButtonUp, wmRButtonDown, wmRButtonUp: + x, y := mouseXY(lParam) + appendEvent(hidEvent{Type: mouseMessageName(msg), X: x, Y: y, Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmMouseWheel: + delta := int16((wParam >> 16) & 0xffff) + appendEvent(hidEvent{Type: "mouse_wheel", WheelDelta: delta, Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmClose: + procShowWindow.Call(hwnd, swHide) + return 0 + case wmDestroy: + procKillTimer.Call(hwnd, dynamicTimerID) + procPostQuitMessage.Call(0) + return 0 + default: + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + } +} + +func mouseMessageName(message uint32) string { + switch message { + case wmLButtonDown: + return "mouse_left_down" + case wmLButtonUp: + return "mouse_left_up" + case wmRButtonDown: + return "mouse_right_down" + case wmRButtonUp: + return "mouse_right_up" + default: + return "mouse_button" + } +} + +func mouseXY(lParam uintptr) (int32, int32) { + x := int16(lParam & 0xffff) + y := int16((lParam >> 16) & 0xffff) + return int32(x), int32(y) +} + +func modifierState() uint32 { + var mods uint32 + if isKeyDown(vkLControl) { + mods |= modLeftCtrl + } + if isKeyDown(vkLShift) { + mods |= modLeftShift + } + if isKeyDown(vkLMenu) { + mods |= modLeftAlt + } + if isKeyDown(vkLWin) { + mods |= modLeftMeta + } + if isKeyDown(vkRControl) { + mods |= modRightCtrl + } + if isKeyDown(vkRShift) { + mods |= modRightShift + } + if isKeyDown(vkRMenu) { + mods |= modRightAlt + } + if isKeyDown(vkRWin) { + mods |= modRightMeta + } + return mods +} + +func isKeyDown(vk uintptr) bool { + ret, _, _ := procGetKeyState.Call(vk) + return int16(ret&0xffff) < 0 +} + +func appendEvent(e hidEvent) { + state.mu.Lock() + defer state.mu.Unlock() + state.events = append(state.events, e) + if len(state.events) > 2048 { + state.events = append([]hidEvent(nil), state.events[len(state.events)-2048:]...) + } +} + +func clearEvents() { + state.mu.Lock() + state.events = nil + state.mu.Unlock() +} + +func snapshotEvents() []hidEvent { + state.mu.Lock() + defer state.mu.Unlock() + out := make([]hidEvent, len(state.events)) + copy(out, state.events) + return out +} + +func setColor(value string) map[string]interface{} { + stopDynamic() + return applyColor(value) +} + +func applyColor(value string) map[string]interface{} { + display := updateColorState(value) + postUIMessage(wmAppInvalidate, 0, 0) + return display +} + +func updateColorState(value string) map[string]interface{} { + colorHex := normalizeColor(value) + state.mu.Lock() + display := setColorStateLocked(colorHex) + state.mu.Unlock() + return display +} + +func startDynamic(fps int) map[string]interface{} { + if fps < 1 { + fps = 60 + } + if fps > 120 { + fps = 120 + } + stopDynamic() + state.mu.Lock() + state.dynamicActive = true + state.dynamicFPS = fps + state.dynamicFrame = 0 + display := setColorStateLocked(dynamicFrameColor(0)) + state.mu.Unlock() + postUIMessage(wmAppDynamicStart, uintptr(dynamicTimerIntervalMS(fps)), 0) + display["dynamic"] = true + display["fps"] = fps + return display +} + +func stopDynamic() { + state.mu.Lock() + active := state.dynamicActive + state.dynamicActive = false + state.dynamicFPS = 0 + state.dynamicFrame = 0 + state.mu.Unlock() + if active { + postUIMessage(wmAppDynamicStop, 0, 0) + } +} + +func dynamicTimerIntervalMS(fps int) int { + if fps < 1 { + fps = 60 + } + interval := 1000 / fps + if interval < 1 { + return 1 + } + return interval +} + +func dynamicFrameColor(frame int64) string { + r := uint8((frame*5 + 31) % 256) + g := uint8((frame*13 + 97) % 256) + b := uint8((frame*29 + 173) % 256) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +func advanceDynamicFrame(hwnd uintptr) { + state.mu.Lock() + if !state.dynamicActive { + state.mu.Unlock() + return + } + state.dynamicFrame++ + setColorStateLocked(dynamicFrameColor(state.dynamicFrame)) + state.mu.Unlock() + invalidateWindowNow(hwnd) +} + +func setColorStateLocked(colorHex string) map[string]interface{} { + color := parseColor(colorHex) + qpc := qpcNow() + unixNano := time.Now().UnixNano() + state.bgColor = color + state.colorHex = colorHex + state.lastColorChangeQpc = qpc + state.lastColorChangeUnix = unixNano + state.colorSequence++ + seq := state.colorSequence + return map[string]interface{}{ + "color": colorHex, + "qpc": qpc, + "unix_nano": unixNano, + "last_change_qpc": qpc, + "last_change_unix_nano": unixNano, + "sequence": seq, + } +} + +func currentDisplayState() map[string]interface{} { + state.mu.Lock() + defer state.mu.Unlock() + return map[string]interface{}{ + "color": state.colorHex, + "last_change_qpc": state.lastColorChangeQpc, + "last_change_unix_nano": state.lastColorChangeUnix, + "sequence": state.colorSequence, + "dynamic": state.dynamicActive, + "dynamic_fps": state.dynamicFPS, + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + } +} + +func showWindow(show bool) { + state.mu.Lock() + hwnd := state.hwnd + state.mu.Unlock() + if hwnd == 0 { + return + } + if show { + postUIMessage(wmAppShow, 0, 0) + } else { + postUIMessage(wmAppHide, 0, 0) + } +} + +func focusWindow() { + state.mu.Lock() + hwnd := state.hwnd + state.mu.Unlock() + if hwnd != 0 { + postUIMessage(wmAppFocus, 0, 0) + } +} + +func postUIMessage(message uint32, wParam uintptr, lParam uintptr) { + state.mu.Lock() + hwnd := state.hwnd + state.mu.Unlock() + if hwnd != 0 { + procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam) + } +} + +func invalidateWindowNow(hwnd uintptr) { + procInvalidateRect.Call(hwnd, 0, 1) + procUpdateWindow.Call(hwnd) +} + +func disableIME(hwnd uintptr) { + if hwnd != 0 { + procImmAssociateContext.Call(hwnd, 0) + } +} + +func parseColor(value string) uint32 { + s := strings.TrimPrefix(strings.TrimSpace(value), "#") + if len(s) != 6 { + return 0x0000ff00 + } + var rgb uint64 + _, err := fmt.Sscanf(s, "%06x", &rgb) + if err != nil { + return 0x0000ff00 + } + r := rgb >> 16 & 0xff + g := rgb >> 8 & 0xff + b := rgb & 0xff + return uint32(r | (g << 8) | (b << 16)) +} + +func normalizeColor(value string) string { + s := strings.TrimPrefix(strings.TrimSpace(value), "#") + if len(s) != 6 { + return "#00ff00" + } + var rgb uint64 + if _, err := fmt.Sscanf(s, "%06x", &rgb); err != nil { + return "#00ff00" + } + return fmt.Sprintf("#%06x", rgb) +} + +func screenSize() (int, int) { + return screenWidth(), screenHeight() +} + +func screenInfo() map[string]int { + width, height := screenSize() + return map[string]int{"width": width, "height": height} +} + +func screenWidth() int { + r, _, _ := procGetSystemMetrics.Call(0) + return int(r) +} + +func screenHeight() int { + r, _, _ := procGetSystemMetrics.Call(1) + return int(r) +} + +func hostname() string { + h, err := os.Hostname() + if err != nil { + return "unknown" + } + return h +} + +func getModuleHandle() uintptr { + ret, _, _ := procGetModuleHandleW.Call(0) + return ret +} + +func qpcNow() int64 { + var value int64 + procQueryPerformanceCount.Call(uintptr(unsafe.Pointer(&value))) + return value +} + +func qpcFreq() int64 { + var value int64 + procQueryPerformanceFreq.Call(uintptr(unsafe.Pointer(&value))) + return value +} + +func listDrives() []driveInfo { + mask, _, _ := procGetLogicalDrives.Call() + now := time.Now().UnixNano() + drives := []driveInfo{} + for i := 0; i < 26; i++ { + if mask&(1< 1024*1024 { + chunk = 1024 * 1024 + } + chunk -= chunk % alignment + if chunk <= 0 { + return nil, fmt.Errorf("invalid uncached read chunk for alignment %d", alignment) + } + var read uint32 + ret, _, err := procReadFile.Call( + handle, + uintptr(unsafe.Pointer(&buf[total])), + uintptr(chunk), + uintptr(unsafe.Pointer(&read)), + 0, + ) + if ret == 0 { + return nil, winCallError("ReadFile FILE_FLAG_NO_BUFFERING", err) + } + if read == 0 { + break + } + total += int(read) + } + if total != sizeBytes { + return nil, fmt.Errorf("short uncached read: %d/%d", total, sizeBytes) + } + out := make([]byte, sizeBytes) + copy(out, buf) + return out, nil +} + +func winCallError(operation string, err error) error { + if errno, ok := err.(syscall.Errno); ok && errno == 0 { + return fmt.Errorf("%s failed", operation) + } + return fmt.Errorf("%s failed: %w", operation, err) +} diff --git a/test/okvm-test/okvm_media.py b/test/okvm-test/okvm_media.py new file mode 100644 index 00000000..4f2edec2 --- /dev/null +++ b/test/okvm-test/okvm_media.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import io +from typing import Any + + +HDMI_COLOR_SEQUENCE: tuple[tuple[str, str], ...] = ( + ("black", "#000000"), + ("white", "#ffffff"), + ("gray16", "#101010"), + ("gray128", "#808080"), + ("gray235", "#ebebeb"), + ("red", "#ff0000"), + ("green", "#00ff00"), + ("blue", "#0000ff"), +) + + +def hex_to_rgb(value: str) -> tuple[int, int, int]: + s = value.strip().lstrip("#") + if len(s) != 6: + raise ValueError(f"invalid RGB color: {value}") + return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) + + +def rgb_error(expected: tuple[int, int, int], measured: tuple[float, float, float]) -> dict[str, float]: + errors = [abs(float(measured[i]) - float(expected[i])) for i in range(3)] + return { + "mean_abs_error": sum(errors) / 3, + "max_abs_error": max(errors), + } + + +def closest_hdmi_color(measured: tuple[float, float, float]) -> tuple[str, float]: + best_name = "" + best_error = 999.0 + for name, color in HDMI_COLOR_SEQUENCE: + err = rgb_error(hex_to_rgb(color), measured)["mean_abs_error"] + if err < best_error: + best_name = name + best_error = err + return best_name, best_error + + +def jpeg_rgb_stats(frame: bytes) -> dict[str, Any]: + try: + from PIL import Image, ImageStat + except ImportError as exc: + raise RuntimeError("Pillow is required for HDMI color/latency tests; run pip install -r requirements.txt") from exc + + with Image.open(io.BytesIO(frame)) as image: + rgb = image.convert("RGB") + width, height = rgb.size + left = max(0, int(width * 0.35)) + top = max(0, int(height * 0.35)) + right = min(width, int(width * 0.65)) + bottom = min(height, int(height * 0.65)) + crop = rgb.crop((left, top, right, bottom)) + stat = ImageStat.Stat(crop) + return { + "width": width, + "height": height, + "sample_box": [left, top, right, bottom], + "mean_rgb": tuple(round(float(v), 2) for v in stat.mean), + "stddev_rgb": tuple(round(float(v), 2) for v in stat.stddev), + } + + +def percentile(values: list[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + rank = (len(ordered) - 1) * (pct / 100) + lower = int(rank) + upper = min(lower + 1, len(ordered) - 1) + weight = rank - lower + return ordered[lower] * (1 - weight) + ordered[upper] * weight diff --git a/test/okvm-test/okvm_report.py b/test/okvm-test/okvm_report.py new file mode 100644 index 00000000..f2c0b98c --- /dev/null +++ b/test/okvm-test/okvm_report.py @@ -0,0 +1,671 @@ +from __future__ import annotations + +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class CheckResult: + name: str + status: str + message: str = "" + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Metric: + name: str + value: Any + unit: str = "" + labels: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Evidence: + category: str + title: str + path: str + + +STATUS_TEXT = { + "PASS": "通过", + "FAIL": "失败", + "WARN": "警告", + "SKIP": "跳过", +} + +STATUS_COLOR = { + "PASS": "\033[32m", + "FAIL": "\033[31m", + "WARN": "\033[33m", + "SKIP": "\033[33m", + "INFO": "\033[36m", +} + +CATEGORIES_WITH_DATA_TABLE = { + "初始化与环境", + "视频性能", + "HDMI 画面与颜色", + "HID / MSD / ATX", +} + +CATEGORIES_WITHOUT_DATA_TABLE: set[str] = set() + +REPORT_HIDDEN_RESULTS = { + "log_collection", + "screenshot", +} + +CATEGORY_RULES = ( + ("初始化与环境", ("target_", "setup_", "login", "network_", "target_inventory", "stream_codecs", "windows_agent", "ventoy_resources", "msd_restart")), + ("视频性能", ("video_", "config_video_")), + ("HDMI 画面与颜色", ("hdmi_", "config_video_hdmi_probe")), + ("HID / MSD / ATX", ("hid_", "msd", "atx_")), +) + +DISPLAY_NAMES = { + "target_reset": "目标机重置", + "setup_init": "初始化账号", + "login": "登录", + "login_after_restart": "重启后登录", + "network_latency": "网络延迟", + "target_inventory": "目标机设备清单", + "hid_msd_config": "HID/MSD 配置", + "ventoy_resources": "Ventoy 资源", + "msd_restart": "MSD 启用后重启", + "video_input_select": "视频输入选择", + "windows_agent": "Windows 配套程序连接", + "config_video_hdmi_probe": "HDMI 采集配置", + "hdmi_identity": "HDMI 画面来源确认", + "hdmi_color_range": "HDMI 颜色偏差", + "hdmi_latency_mjpeg": "MJPEG 画面延迟", + "hid_status": "HID 状态", + "hid_input": "HID 输入", + "hid_latency": "HID 延迟", + "msd": "MSD 虚拟盘", + "atx_api": "ATX API", + "log_collection": "日志采集", + "screenshot": "网页截图", +} + + +class Reporter: + def __init__(self, report_dir: Path, color: bool | None = None): + self.report_dir = report_dir + self.results: list[CheckResult] = [] + self.metrics: list[Metric] = [] + self.evidence: list[Evidence] = [] + self.report_dir.mkdir(parents=True, exist_ok=True) + (self.report_dir / "evidence").mkdir(exist_ok=True) + self.use_color = color if color is not None else (sys.stdout.isatty() and not os.environ.get("NO_COLOR")) + + def add(self, check_name: str, outcome: str, detail: str = "", **data: Any) -> None: + self.results.append(CheckResult(name=check_name, status=outcome, message=detail, data=data)) + prefix = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIP": "[SKIP]", "WARN": "[WARN]"}.get(outcome, "[INFO]") + print(f"{self.colorize(prefix, outcome)} {check_name}: {detail}") + + def info(self, check_name: str, detail: str) -> None: + print(f"{self.colorize('[INFO]', 'INFO')} {check_name}: {detail}") + + def metric(self, name: str, value: Any, unit: str = "", **labels: Any) -> None: + self.metrics.append(Metric(name=name, value=value, unit=unit, labels=labels)) + + def add_evidence(self, category: str, title: str, path: Path) -> None: + rel = path.relative_to(self.report_dir) if path.is_relative_to(self.report_dir) else path + self.evidence.append(Evidence(category=category, title=title, path=str(rel))) + + def write(self, run_id: str, target: str) -> None: + generated_at = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime()) + report_timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime()) + status = "失败" if any(r.status == "FAIL" for r in self.report_results()) else "通过" + md_path = self.report_dir / f"report-{report_timestamp}.md" + user_md_path = self.report_dir / f"user-report-{report_timestamp}.md" + md_path.write_text(self.render_markdown(run_id, target, generated_at, status), encoding="utf-8") + user_md_path.write_text(self.render_user_markdown(run_id), encoding="utf-8") + self.info("markdown_report", str(md_path)) + self.info("user_markdown_report", str(user_md_path)) + + def colorize(self, text: str, status: str) -> str: + if not self.use_color: + return text + return f"{STATUS_COLOR.get(status, '')}{text}\033[0m" + + def render_markdown(self, run_id: str, target: str, generated_at: str, status: str) -> str: + counts = self.status_counts() + lines = [ + "# One-KVM 自动化测试报告", + "", + f"- 运行编号:`{run_id}`", + f"- 目标设备:`{target}`", + f"- 生成时间:`{generated_at}`", + f"- 总体结果:**{status}**", + f"- 统计:通过 {counts.get('PASS', 0)},警告 {counts.get('WARN', 0)},跳过 {counts.get('SKIP', 0)},失败 {counts.get('FAIL', 0)}", + "", + ] + + grouped = self.group_results() + for category, results in grouped.items(): + if not results: + continue + lines.extend([f"## {category}", ""]) + lines.extend(self.render_result_table(category, results)) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + def render_user_markdown(self, run_id: str) -> str: + lines = [ + "# One-KVM 测试摘要", + "", + "## 背景", + "", + f"- 运行编号:`{run_id}`", + "- 测试设备:", + f"- 视频设备:{markdown_inline(self.user_video_device())}", + f"- HID设备:{markdown_inline(self.user_hid_backend())}", + f"- 网络延迟:{markdown_inline(self.user_network_latency())}", + "", + "## 视频性能", + "", + "| 视频输入参数 | 测试帧率 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max) |", + "| --- | --- | --- |", + ] + video_rows = self.user_video_rows() + if video_rows: + for params, fps, latency in video_rows: + lines.append( + "| " + + " | ".join( + ( + markdown_table_cell(params), + markdown_table_cell(fps), + markdown_table_cell(latency), + ) + ) + + " |" + ) + else: + lines.append("| 无数据 | - | - |") + + lines.extend( + [ + "", + "## HID性能", + "", + "| 输入方式 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max) |", + "| --- | --- |", + ] + ) + hid_rows = self.user_hid_rows() + if hid_rows: + for method, latency in hid_rows: + lines.append(f"| {markdown_table_cell(method)} | {markdown_table_cell(latency)} |") + else: + lines.append("| 无数据 | - |") + + lines.extend( + [ + "", + "## MSD性能", + "", + "| 操作 | 数据 |", + "| --- | --- |", + ] + ) + msd_rows = self.user_msd_rows() + if msd_rows: + for operation, data in msd_rows: + lines.append(f"| {markdown_table_cell(operation)} | {markdown_table_cell(data)} |") + else: + lines.append("| 无数据 | - |") + + return "\n".join(lines).rstrip() + "\n" + + def find_result(self, name: str) -> CheckResult | None: + for result in self.results: + if result.name == name: + return result + return None + + def user_network_latency(self) -> str: + result = self.find_result("network_latency") + if not result: + return "无数据" + tcp = result.data.get("tcp_connect") or {} + if tcp.get("samples"): + return format_latency_values(tcp) + return result.message or "无数据" + + def user_video_device(self) -> str: + result = self.find_result("video_input_select") + cases = result.data.get("cases") if result else None + if not cases: + return "无数据" + by_device: dict[str, list[str]] = {} + for case in cases: + if not isinstance(case, dict): + continue + device = str(case.get("device") or "未知设备") + by_device.setdefault(device, []).append(format_video_case(case)) + return ";".join(f"{device}:{', '.join(items)}" for device, items in by_device.items()) or "无数据" + + def user_hid_backend(self) -> str: + config = self.find_result("hid_msd_config") + if config: + if config.data.get("udc"): + return "OTG" + if config.data.get("port"): + return "CH9329" + status = self.find_result("hid_status") + if status: + data = status.data.get("status") or {} + backend = str(data.get("backend") or data.get("hid_backend") or "").strip() + if backend: + return backend.upper() if backend.lower() == "otg" else backend + return "无数据" + + def user_video_rows(self) -> list[tuple[str, str, str]]: + fps_by_key: dict[tuple[str, str], str] = {} + for result in self.results: + if not result.name.startswith("video_") or result.name.startswith("video_latency_"): + continue + data = result.data + case = data.get("case") or {} + if not isinstance(case, dict): + continue + output_mode = str(data.get("codec") or result.name.rsplit("_", 1)[-1]).lower() + label = str(case.get("label") or "") + fps = data.get("fps") + if fps is None: + fps = data.get("avgFps") + if label and output_mode and fps is not None: + fps_by_key[(label, output_mode)] = format_fps(float(fps)) + + rows: list[tuple[str, str, str]] = [] + for result in self.results: + if not result.name.startswith("video_latency_"): + continue + data = result.data + case = data.get("video_case") or {} + if not isinstance(case, dict): + case = {} + output_mode = str(data.get("output_mode") or video_latency_output_from_name(result.name) or "").lower() + label = str(case.get("label") or "") + params = format_video_latency_params(case, output_mode) + fps = fps_by_key.get((label, output_mode), "-") + latency = format_latency_data(data) + if not latency: + latency = f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status) + rows.append((params, fps, latency)) + return rows + + def user_hid_rows(self) -> list[tuple[str, str]]: + result = self.find_result("hid_latency") + if not result: + return [] + latency = format_latency_data(result.data) + if not latency: + latency = f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status) + return [(self.user_hid_backend(), latency)] + + def user_msd_rows(self) -> list[tuple[str, str]]: + result = self.find_result("msd") + if not result: + return [] + verify = result.data.get("verify") or {} + rows: list[tuple[str, str]] = [] + if "write_mib_s" in verify: + rows.append(("写", f"{float(verify.get('write_mib_s') or 0):.2f}MiB/s")) + if "read_mib_s" in verify: + rows.append(("读", f"{float(verify.get('read_mib_s') or 0):.2f}MiB/s")) + elif "cached_read_mib_s" in verify: + rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s")) + if rows: + return rows + return [("MSD", f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status))] + + def report_results(self) -> list[CheckResult]: + return [result for result in self.results if result.name not in REPORT_HIDDEN_RESULTS] + + def status_counts(self) -> dict[str, int]: + counts: dict[str, int] = {} + for result in self.report_results(): + counts[result.status] = counts.get(result.status, 0) + 1 + return counts + + def group_results(self) -> dict[str, list[CheckResult]]: + grouped = {name: [] for name, _ in CATEGORY_RULES} + grouped["其他"] = [] + for result in self.report_results(): + for category, prefixes in CATEGORY_RULES: + if any(result.name == prefix or result.name.startswith(prefix) for prefix in prefixes): + grouped[category].append(result) + break + else: + grouped["其他"].append(result) + return grouped + + def render_result(self, result: CheckResult) -> list[str]: + title = display_name(result.name) + status = STATUS_TEXT.get(result.status, result.status) + lines = [f"### {title}", "", f"- 结果:**{status}**"] + summary = summarize_result(result, self.metrics) + if summary: + lines.append(f"- 数据:{summary}") + if result.status == "FAIL": + lines.append(f"- 失败日志:`{markdown_inline(result.message)}`") + elif result.status in {"WARN", "SKIP"} and result.message: + lines.append(f"- 说明:{markdown_inline(result.message)}") + return lines + + def render_result_table(self, category: str, results: list[CheckResult]) -> list[str]: + with_data = category in CATEGORIES_WITH_DATA_TABLE or ( + category not in CATEGORIES_WITHOUT_DATA_TABLE + and any(has_numeric_data(result, self.metrics) for result in results) + ) + if with_data: + lines = ["| 项目 | 结果 | 数据 |", "| --- | --- | --- |"] + for result in results: + lines.append( + "| " + + " | ".join( + ( + markdown_table_cell(display_name(result.name)), + markdown_table_cell(STATUS_TEXT.get(result.status, result.status)), + markdown_table_cell(summarize_result(result, self.metrics)), + ) + ) + + " |" + ) + else: + lines = ["| 项目 | 结果 |", "| --- | --- |"] + for result in results: + lines.append( + "| " + + " | ".join( + ( + markdown_table_cell(display_name(result.name)), + markdown_table_cell(STATUS_TEXT.get(result.status, result.status)), + ) + ) + + " |" + ) + + sections = ( + ("失败项目", [result for result in results if result.status == "FAIL"]), + ("跳过项目", [result for result in results if result.status == "SKIP"]), + ("警告项目", [result for result in results if result.status == "WARN"]), + ) + for heading, items in sections: + if not items: + continue + lines.extend(["", f"{heading}:"]) + for result in items: + title = display_name(result.name) + status = STATUS_TEXT.get(result.status, result.status) + reason = result.message or summarize_result(result, self.metrics) or "无详细日志" + lines.append(f"- **{markdown_inline(title)}**({status}):{markdown_inline(reason)}") + return lines + + +def summarize_result(result: CheckResult, metrics: list[Metric]) -> str: + data = result.data + name = result.name + if name == "network_latency": + tcp = data.get("tcp_connect") or {} + http = data.get("http_health") or {} + parts = [] + if tcp.get("samples"): + parts.append( + f"TCP p50={float(tcp.get('p50_ms', 0)):.1f}ms,p95={float(tcp.get('p95_ms', 0)):.1f}ms,max={float(tcp.get('max_ms', 0)):.1f}ms" + ) + if http.get("samples"): + parts.append( + f"HTTP p50={float(http.get('p50_ms', 0)):.1f}ms,p95={float(http.get('p95_ms', 0)):.1f}ms,max={float(http.get('max_ms', 0)):.1f}ms" + ) + errors = data.get("errors") or [] + if errors: + parts.append(f"异常样本={len(errors)}") + return ";".join(parts) + if name.startswith("config_video_") and isinstance(data.get("case"), dict): + case = data["case"] + return f"{case.get('device')} {case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}" + if name.startswith("video_latency_") or name == "hdmi_latency_mjpeg": + if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")): + return "" + case = data.get("video_case") or {} + output_mode = str(data.get("output_mode") or video_latency_output_from_name(name) or "").upper() + parts = [] + if output_mode: + parts.append(output_mode) + if isinstance(case, dict) and case.get("fmt"): + parts.append(f"{case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}") + parts.extend( + [ + f"p50={float(data.get('p50_ms', 0)):.1f}ms", + f"p95={float(data.get('p95_ms', 0)):.1f}ms", + f"max={float(data.get('max_ms', 0)):.1f}ms", + ] + ) + return ",".join(parts) + if name.startswith("video_"): + parts = [] + if "fps" in data: + parts.append(f"fps={float(data['fps']):.1f}") + if "avgFps" in data: + parts.append(f"平均fps={float(data['avgFps']):.1f}") + if "input_requested_fps" in data: + parts.append(f"请求输入={float(data['input_requested_fps']):.1f}fps") + if "maxRtt" in data: + parts.append(f"最大RTT={float(data['maxRtt']) * 1000:.1f}ms") + if "min_expected_fps" in data: + parts.append(f"目标下限={float(data['min_expected_fps']):.1f}fps") + if "dynamic_source_fps" in data: + parts.append(f"动态源={int(data['dynamic_source_fps'])}fps") + if data.get("fps_above_requested"): + parts.append("输出帧率高于请求输入") + if data.get("unsupported"): + parts.append("不支持") + return ",".join(parts) + if name == "hdmi_identity": + samples = data.get("samples") or [] + ok = sum(1 for item in samples if item.get("identity_match")) + parts = [f"颜色匹配 {ok}/{len(samples)}"] + if samples: + detail = [] + for item in samples: + rgb = item.get("measured_rgb_mean") + status = "匹配" if item.get("identity_match") else "不匹配" + detail.append(f"{item.get('name')}={rgb}({status})") + parts.append(";".join(detail)) + return ",".join(parts) + if name == "hdmi_color_range": + samples = data.get("samples") or [] + if not samples: + return result.message + worst = max(samples, key=lambda item: float(item.get("mean_abs_error") or 0)) + missed = sum(1 for item in samples if not item.get("target_detected")) + max_mean_error = max((float(item.get("mean_abs_error") or 0) for item in samples), default=0.0) + max_abs_error = max((float(item.get("max_abs_error") or 0) for item in samples), default=0.0) + return ( + f"最大平均误差={max_mean_error:.1f},最大通道误差={max_abs_error:.1f}," + f"未命中颜色={missed}/{len(samples)}," + f"最差={worst.get('name')} 实测RGB={worst.get('measured_rgb_mean')}," + f"采样帧={worst.get('frames_seen', 0)}" + ) + if name == "hid_input": + count = int(data.get("event_count") or len(data.get("events") or [])) + keyboard = data.get("keyboard") or {} + mouse = data.get("mouse") or {} + parts = [ + f"捕获事件数={count}", + f"字母数字={keyboard.get('alphanumeric_tested', 0)}", + f"功能键={keyboard.get('function_keys_tested', 0)}", + f"安全组合键={keyboard.get('safe_combos_tested', 0)}", + f"绝对鼠标={'通过' if mouse.get('absolute_ok') else '失败'}", + f"相对鼠标={'通过' if mouse.get('relative_ok') else '失败'}", + ] + missing = [] + for key in ( + "missing_alphanumeric_down", + "missing_alphanumeric_up", + "missing_function_down", + "missing_function_up", + "missing_combos", + ): + values = keyboard.get(key) or [] + if values: + missing.append(f"{key}={','.join(map(str, values[:10]))}") + if missing: + parts.append("缺失:" + ";".join(missing)) + return ",".join(parts) + if name == "hid_latency": + if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")): + return "" + parts = [ + f"按键={data.get('key', '')}", + f"p50={float(data.get('p50_ms', 0)):.1f}ms", + f"p95={float(data.get('p95_ms', 0)):.1f}ms", + f"max={float(data.get('max_ms', 0)):.1f}ms", + ] + missing = int(data.get("missing_trials") or 0) + if missing: + parts.append(f"未捕获={missing}") + return ",".join(parts) + if name == "msd": + verify = data.get("verify") or {} + size = verify.get("size_bytes") + if not size: + return "" + parts = [f"读写校验大小={size} bytes"] + if "write_mib_s" in verify: + parts.append(f"写入={float(verify.get('write_mib_s') or 0):.2f}MiB/s") + if "read_mib_s" in verify: + parts.append(f"未缓存读取={float(verify.get('read_mib_s') or 0):.2f}MiB/s") + elif "cached_read_mib_s" in verify: + parts.append(f"缓存读取={float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s(仅校验)") + if "write_ms" in verify and "read_ms" in verify: + parts.append(f"写耗时={float(verify.get('write_ms') or 0):.1f}ms,读耗时={float(verify.get('read_ms') or 0):.1f}ms") + if verify.get("read_cached"): + parts.append("读速未作为真实盘读速") + return ",".join(parts) + if name == "atx_api": + return "status/config/WOL API 均有响应" + if name == "windows_agent": + hello = data.get("hello") or {} + return f"主机={hello.get('hostname', 'unknown')}" + if name == "target_inventory": + return "已采集 lsusb、uname、服务状态" + if name == "target_reset": + return "数据库已备份,服务已重启" + if name == "setup_init": + return "初始化检查完成" + if name in {"login", "login_after_restart"}: + return "认证成功" + if name == "hid_msd_config": + return "已按设备能力配置 HID/MSD" + if name == "ventoy_resources": + return "Ventoy 资源检查完成" + if name == "msd_restart": + return "服务重启完成" + if name == "video_input_select": + cases = data.get("cases") or [] + return ",".join(f"{c.get('fmt')} {c.get('width')}x{c.get('height')}@{c.get('fps')}" for c in cases if isinstance(c, dict)) + return "" + + +def display_name(name: str) -> str: + if name.startswith("video_latency_"): + body = name.removeprefix("video_latency_") + for mode in ("mjpeg", "h264", "h265"): + suffix = f"_{mode}" + if body == mode: + return f"视频延迟 {mode.upper()}" + if body.endswith(suffix): + label = body[: -len(suffix)] + return f"视频延迟 {label} {mode.upper()}" + return f"视频延迟 {body}" + return DISPLAY_NAMES.get(name, name) + + +def format_video_case(case: dict[str, Any]) -> str: + fmt = str(case.get("fmt") or "").lower() + resolution = format_resolution(case) + fps = format_fps_value(case.get("fps")) + return " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) + + +def format_video_latency_params(case: dict[str, Any], output_mode: str) -> str: + fmt = str(case.get("fmt") or "").lower() + output = output_mode.lower() if output_mode else "unknown" + resolution = format_resolution(case) + fps = format_fps_value(case.get("fps")) + input_part = " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) + return f"{input_part}-->{output}" if input_part else output + + +def format_resolution(case: dict[str, Any]) -> str: + try: + width = int(case.get("width") or 0) + height = int(case.get("height") or 0) + except Exception: + return "" + if height > 0 and width in {1280, 1920, 2560, 3840, 4096}: + return f"{height}p" + if width > 0 and height > 0: + return f"{width}x{height}" + return "" + + +def format_fps(value: float) -> str: + return f"{value:.0f}fps" if abs(value - round(value)) < 0.05 else f"{value:.1f}fps" + + +def format_fps_value(value: Any) -> str: + try: + return format_fps(float(value)) + except Exception: + return "" + + +def format_latency_data(data: dict[str, Any]) -> str: + if not any(key in data for key in ("p50_ms", "p95_ms", "max_ms")): + return "" + return format_latency_values(data) + + +def format_latency_values(data: dict[str, Any]) -> str: + return ( + f"p50={float(data.get('p50_ms', 0)):.1f}ms," + f"p95={float(data.get('p95_ms', 0)):.1f}ms," + f"max={float(data.get('max_ms', 0)):.1f}ms" + ) + + +def video_latency_output_from_name(name: str) -> str: + body = name.removeprefix("video_latency_") if name.startswith("video_latency_") else name + for mode in ("mjpeg", "h264", "h265"): + if body == mode or body.endswith(f"_{mode}"): + return mode + return "" + + +def has_numeric_data(result: CheckResult, metrics: list[Metric]) -> bool: + summary = summarize_result(result, metrics) + return any(ch.isdigit() for ch in summary) + + +def markdown_inline(value: str) -> str: + return str(value).replace("\n", " | ").replace("`", "'") + + +def markdown_table_cell(value: str) -> str: + return markdown_inline(value).replace("|", "\\|") + + +def markdown_link(path: str) -> str: + return path.replace(" ", "%20") diff --git a/test/okvm-test/okvm_testctl.py b/test/okvm-test/okvm_testctl.py new file mode 100755 index 00000000..bb7761b8 --- /dev/null +++ b/test/okvm-test/okvm_testctl.py @@ -0,0 +1,2319 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import getpass +import inspect +import json +import lzma +import os +import re +import socket +import struct +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from okvm_media import HDMI_COLOR_SEQUENCE, closest_hdmi_color, hex_to_rgb, jpeg_rgb_stats, percentile, rgb_error +from okvm_report import Reporter + +try: + import httpx +except ImportError: # Keep --help usable before dependencies are installed. + httpx = None # type: ignore[assignment] + + +HID_KEY_USAGE: dict[str, tuple[int, int]] = { + **{chr(ord("a") + i): (0x04 + i, 0x00) for i in range(26)}, + **{chr(ord("A") + i): (0x04 + i, 0x02) for i in range(26)}, + "1": (0x1E, 0x00), + "2": (0x1F, 0x00), + "3": (0x20, 0x00), + "4": (0x21, 0x00), + "5": (0x22, 0x00), + "6": (0x23, 0x00), + "7": (0x24, 0x00), + "8": (0x25, 0x00), + "9": (0x26, 0x00), + "0": (0x27, 0x00), + "-": (0x2D, 0x00), + "_": (0x2D, 0x02), + " ": (0x2C, 0x00), + "\n": (0x28, 0x00), +} + +HID_ALNUM_KEYS: list[tuple[str, int, int]] = [ + *[(chr(ord("A") + i), 0x04 + i, 0x41 + i) for i in range(26)], + *[(str((i + 1) % 10), 0x1E + i, 0x31 + i if i < 9 else 0x30) for i in range(10)], +] +HID_ALNUM_TEXT = "abcdefghijklmnopqrstuvwxyz1234567890" +HID_FUNCTION_KEYS: list[tuple[str, int, int]] = [ + (f"F{i}", 0x39 + i, 0x6F + i) for i in range(1, 13) +] + +MOD_LEFT_CTRL = 0x01 +MOD_LEFT_SHIFT = 0x02 + +HID_SAFE_COMBOS: list[tuple[str, int, int, int]] = [ + ("Ctrl+F9", 0x42, 0x78, MOD_LEFT_CTRL), + ("Shift+F11", 0x44, 0x7A, MOD_LEFT_SHIFT), + ("Ctrl+Shift+F12", 0x45, 0x7B, MOD_LEFT_CTRL | MOD_LEFT_SHIFT), +] +HID_LATENCY_KEY = ("F8", 0x41, 0x77) +VIDEO_LATENCY_OUTPUT_MODES = ("mjpeg", "h264", "h265") +CHROME_BROWSER_CHANNEL = "chrome" +CHROME_BROWSER_NAME = "Chrome" +CHROME_INSTALL_COMMAND = "python -m playwright install chrome" +CHROMIUM_WINDOWS_ARGS = [ + "--enable-features=WebRtcAllowH265Receive", + "--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled", + "--enable-gpu", + "--ignore-gpu-blocklist", + "--use-angle=d3d11", +] + +@dataclass +class VideoInputCase: + label: str + input_class: str + device: str + fmt: str + width: int + height: int + fps: float + +class SSHRunner: + def __init__(self, host: str, user: str, password: str | None, port: int = 22): + self.host = host + self.user = user + self.password = password + self.port = port + + def connect(self) -> Any: + try: + import paramiko + except ImportError as exc: + raise RuntimeError("paramiko is required for SSH; run pip install -r requirements.txt") from exc + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + self.host, + port=self.port, + username=self.user, + password=self.password, + look_for_keys=self.password is None, + allow_agent=self.password is None, + timeout=15, + ) + return client + + def run(self, command: str, timeout: int = 60) -> tuple[int, str, str]: + client = self.connect() + try: + _, stdout, stderr = client.exec_command(command, timeout=timeout) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + return code, out, err + finally: + client.close() + + +class ApiClient: + def __init__(self, target: str, port: int, timeout: float = 15.0): + if httpx is None: + raise RuntimeError("httpx is required; run pip install -r requirements.txt") + self.base = f"http://{target}:{port}" + self.client = httpx.Client(base_url=self.base, timeout=timeout, follow_redirects=True) + + def close(self) -> None: + self.client.close() + + def cookie_header(self) -> str: + return "; ".join(f"{cookie.name}={cookie.value}" for cookie in self.client.cookies.jar) + + def request(self, method: str, path: str, **kwargs: Any) -> Any: + response = self.client.request(method, f"/api{path}", **kwargs) + content_type = response.headers.get("content-type", "") + data: Any + if "json" in content_type: + data = response.json() + else: + try: + data = response.json() + except Exception: + data = response.text + if response.status_code >= 400: + raise RuntimeError(f"{method} {path} -> HTTP {response.status_code}: {data}") + if isinstance(data, dict) and data.get("success") is False: + raise RuntimeError(f"{method} {path} failed: {data}") + return data + + def get(self, path: str) -> Any: + return self.request("GET", path) + + def post(self, path: str, payload: dict[str, Any] | None = None) -> Any: + return self.request("POST", path, json=payload or {}) + + def patch(self, path: str, payload: dict[str, Any]) -> Any: + return self.request("PATCH", path, json=payload) + + def wait_health(self, timeout: int = 60) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + return self.get("/health") + except Exception as exc: + last_error = exc + time.sleep(1) + raise TimeoutError(f"One-KVM health check did not pass in {timeout}s: {last_error}") + + +class AgentClient: + def __init__(self, host: str, port: int, reporter: Reporter): + self.host = host + self.port = port + self.reporter = reporter + self.websocket: Any = None + self.hello: dict[str, Any] | None = None + self.connected = asyncio.Event() + self.lock = asyncio.Lock() + + async def stop(self) -> None: + if self.websocket: + await self.websocket.close() + self.websocket = None + self.connected.clear() + + async def wait_connected(self, timeout: int = 180) -> bool: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + await self._connect_once() + return True + except Exception as exc: + last_error = exc + await asyncio.sleep(1) + self.reporter.add("windows_agent", "FAIL", f"failed to connect Windows agent: {last_error}") + return False + + async def _connect_once(self) -> None: + if self.websocket and self.connected.is_set(): + return + try: + import websockets + except ImportError as exc: + raise RuntimeError("websockets is required for agent support; run pip install -r requirements.txt") from exc + + uri = f"ws://{self.host}:{self.port}/agent" + self.websocket = await websockets.connect(uri, max_size=8 * 1024 * 1024) + raw = await asyncio.wait_for(self.websocket.recv(), 10) + data = json.loads(raw) + if data.get("type") != "hello": + raise RuntimeError(f"unexpected agent hello: {data}") + self.hello = data + self.connected.set() + self.reporter.add("windows_agent", "PASS", "connected to Windows agent", hello=data) + + async def command(self, name: str, payload: dict[str, Any] | None = None, timeout: int = 30) -> dict[str, Any]: + if not self.websocket or not self.connected.is_set(): + raise RuntimeError("Windows agent is not connected") + msg_id = str(uuid.uuid4()) + async with self.lock: + await self.websocket.send(json.dumps({"id": msg_id, "command": name, "payload": payload or {}})) + response = json.loads(await asyncio.wait_for(self.websocket.recv(), timeout)) + if not response.get("ok"): + raise RuntimeError(response.get("error") or f"agent command failed: {name}") + return response.get("payload") or {} + + +class DeviceSelector: + CSI_HINTS = ("rkcif", "rk_hdmirx", "mipi", "csi", "platform", "hdmirx") + + def __init__(self, lsusb_tree: str, devices: dict[str, Any]): + self.lsusb_tree = lsusb_tree + self.devices = devices + + def classify(self, device: dict[str, Any]) -> str: + haystack = " ".join( + str(device.get(k, "")) for k in ("name", "driver", "path") + ).lower() + if any(h in haystack for h in self.CSI_HINTS) or not device.get("usb_bus"): + return "csi_mipi" + if re.search(r"\b(5000|10000|20000)M\b", self.lsusb_tree): + return "usb3" + return "usb2" + + @staticmethod + def _find_format(device: dict[str, Any], fmt: str) -> dict[str, Any] | None: + want = fmt.upper() + for item in device.get("formats", []): + got = str(item.get("format", "")).upper() + if want in got or got in want: + return item + return None + + @staticmethod + def _pick_exact(fmt: dict[str, Any], width: int, height: int, fps: float) -> tuple[int, int, float] | None: + for res in fmt.get("resolutions", []): + if int(res.get("width", 0)) == width and int(res.get("height", 0)) == height: + fps_values = [float(x) for x in res.get("fps", [])] + if not fps_values: + continue + best = min(fps_values, key=lambda x: abs(x - fps)) + if best >= fps * 0.9: + return width, height, best + return None + + @staticmethod + def _pick_highest_1080(fmt: dict[str, Any]) -> tuple[int, int, float] | None: + candidates: list[tuple[int, int, float]] = [] + for res in fmt.get("resolutions", []): + width = int(res.get("width", 0)) + height = int(res.get("height", 0)) + if width > 1920 or height > 1080: + continue + for fps in res.get("fps", []): + candidates.append((width, height, float(fps))) + if not candidates: + return None + return max(candidates, key=lambda x: (x[0] * x[1], x[2])) + + def select(self) -> list[VideoInputCase]: + video_devices = self.devices.get("video", []) + if not video_devices: + return [] + ordered = sorted(video_devices, key=lambda d: (not bool(d.get("has_signal")), d.get("path", ""))) + device = ordered[0] + input_class = self.classify(device) + path = str(device["path"]) + cases: list[VideoInputCase] = [] + + if input_class == "csi_mipi": + nv12 = self._find_format(device, "NV12") + if nv12: + picked = self._pick_exact(nv12, 1920, 1080, 60) or self._pick_highest_1080(nv12) + if picked: + w, h, f = picked + cases.append(VideoInputCase("csi_mipi_nv12", input_class, path, "NV12", w, h, f)) + return cases + + mjpeg = self._find_format(device, "MJPEG") + yuyv = self._find_format(device, "YUYV") + target_fps = 60 if input_class == "usb3" else 30 + if mjpeg: + picked = self._pick_exact(mjpeg, 1920, 1080, target_fps) or self._pick_highest_1080(mjpeg) + if picked: + w, h, f = picked + cases.append(VideoInputCase(f"{input_class}_mjpeg", input_class, path, "MJPEG", w, h, f)) + if yuyv: + picked = self._pick_highest_1080(yuyv) + if picked: + w, h, f = picked + cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f)) + return cases + + +class AcceptanceRunner: + def __init__(self, args: argparse.Namespace): + self.args = args + self.run_id = args.run_id or time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6] + self.reporter = Reporter(Path(args.report_dir) / self.run_id, color=not args.no_color) + self.api = ApiClient(args.target, args.http_port) + self.ssh_password = args.ssh_password or os.environ.get("OKVM_SSH_PASSWORD") + self.agent: AgentClient | None = None + self.lsusb_tree = "" + self.selected_hid_backend = "none" + self.screenshot_keys: set[str] = set() + + async def run(self) -> int: + if self.args.agent_host: + self.agent = AgentClient(self.args.agent_host, self.args.agent_port, self.reporter) + print(f"Connecting Windows agent at ws://{self.args.agent_host}:{self.args.agent_port}/agent") + + try: + if self.args.reset: + self.reset_target() + self.api.wait_health(timeout=self.args.health_timeout) + self.run_network_latency_test() + self.setup_or_login() + self.collect_target_inventory() + devices = self.api.get("/devices") + self.configure_hid_and_msd(devices) + video_cases = self.select_video_cases(devices) + await self.wait_for_agent_if_requested() + await self.capture_key_screenshot("console_after_login", "登录后控制台首页") + await self.run_video_matrix(video_cases) + await self.run_hdmi_capture_tests(video_cases) + await self.run_hid_test() + await self.run_msd_test() + self.run_atx_api_test() + await self.capture_key_screenshot("console_after_io", "HID/MSD/ATX 后控制台状态") + self.collect_logs() + finally: + if self.agent: + await self.agent.stop() + self.api.close() + self.reporter.write(self.run_id, self.args.target) + + return 1 if any(r.status == "FAIL" for r in self.reporter.results) else 0 + + def reset_target(self) -> None: + if not self.ssh_password and self.args.ssh_password_prompt: + self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ") + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + data_dir = self.args.data_dir + cmd = f""" +set -eu +RUN_ID='{shell_quote(self.run_id)}' +DATA_DIR='{shell_quote(data_dir)}' +BACKUP="$DATA_DIR/test-backups/$RUN_ID" +mkdir -p "$BACKUP" +(systemctl stop one-kvm || service one-kvm stop || true) +if [ -f "$DATA_DIR/one-kvm.db" ]; then cp -a "$DATA_DIR/one-kvm.db" "$BACKUP/one-kvm.db"; fi +if [ -f "$DATA_DIR/one-kvm.db-wal" ]; then cp -a "$DATA_DIR/one-kvm.db-wal" "$BACKUP/one-kvm.db-wal"; fi +if [ -f "$DATA_DIR/one-kvm.db-shm" ]; then cp -a "$DATA_DIR/one-kvm.db-shm" "$BACKUP/one-kvm.db-shm"; fi +rm -f "$DATA_DIR/one-kvm.db" "$DATA_DIR/one-kvm.db-wal" "$DATA_DIR/one-kvm.db-shm" +(systemctl start one-kvm || service one-kvm start || nohup /usr/bin/one-kvm >"/tmp/one-kvm-test-$RUN_ID.log" 2>&1 &) +echo "$BACKUP" +""" + code, out, err = ssh.run(cmd, timeout=60) + if code != 0: + self.reporter.add("target_reset", "FAIL", "failed to reset target", stdout=out, stderr=err) + raise RuntimeError(err or out) + self.reporter.add("target_reset", "PASS", "database backed up and service restarted", backup=out.strip()) + + def setup_or_login(self) -> None: + setup = self.api.get("/setup") + if setup.get("needs_setup"): + self.api.post( + "/setup/init", + { + "username": self.args.web_user, + "password": self.args.web_password, + "hid_backend": "none", + "msd_enabled": False, + "ttyd_enabled": False, + "rustdesk_enabled": False, + }, + ) + self.reporter.add("setup_init", "PASS", "initial setup completed") + else: + self.reporter.add("setup_init", "WARN", "target already initialized; using login path") + self.authenticate("login", "authenticated with One-KVM") + + def authenticate(self, check_name: str, detail: str) -> None: + self.api.post("/auth/login", {"username": self.args.web_user, "password": self.args.web_password}) + self.reporter.add(check_name, "PASS", detail) + + def run_network_latency_test(self) -> None: + samples = max(1, int(self.args.network_latency_samples)) + timeout = max(0.1, float(self.args.network_latency_timeout)) + tcp_values: list[float] = [] + http_values: list[float] = [] + errors: list[str] = [] + + for _ in range(samples): + start = time.perf_counter_ns() + try: + with socket.create_connection((self.args.target, self.args.http_port), timeout=timeout): + pass + tcp_values.append((time.perf_counter_ns() - start) / 1_000_000) + except Exception as exc: + errors.append(f"tcp_connect: {exc}") + time.sleep(0.05) + + for _ in range(samples): + start = time.perf_counter_ns() + try: + self.api.get("/health") + http_values.append((time.perf_counter_ns() - start) / 1_000_000) + except Exception as exc: + errors.append(f"http_health: {exc}") + time.sleep(0.05) + + tcp_stats = latency_stats(tcp_values) + http_stats = latency_stats(http_values) + if tcp_values: + self.reporter.metric("network_tcp_connect_p50", round(float(tcp_stats["p50_ms"]), 2), "ms") + self.reporter.metric("network_tcp_connect_p95", round(float(tcp_stats["p95_ms"]), 2), "ms") + self.reporter.metric("network_tcp_connect_max", round(float(tcp_stats["max_ms"]), 2), "ms") + if http_values: + self.reporter.metric("network_http_health_p50", round(float(http_stats["p50_ms"]), 2), "ms") + self.reporter.metric("network_http_health_p95", round(float(http_stats["p95_ms"]), 2), "ms") + self.reporter.metric("network_http_health_max", round(float(http_stats["max_ms"]), 2), "ms") + + if tcp_values and http_values and not errors: + status = "PASS" + elif tcp_values or http_values: + status = "WARN" + else: + status = "FAIL" + self.reporter.add( + "network_latency", + status, + "measured controller-to-target TCP and HTTP latency" if status != "FAIL" else "failed to measure network latency", + target=self.args.target, + port=self.args.http_port, + samples=samples, + tcp_connect=tcp_stats, + http_health=http_stats, + errors=errors[:10], + ) + + def collect_target_inventory(self) -> None: + if not self.ssh_password and self.args.ssh_password_prompt: + self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ") + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + commands = { + "lsusb-tree.txt": "lsusb -t || true", + "uname.txt": "uname -a || true", + "systemctl-status.txt": "systemctl status one-kvm --no-pager || true", + } + for filename, cmd in commands.items(): + code, out, err = ssh.run(cmd, timeout=30) + text = out + ("\nSTDERR:\n" + err if err else "") + path = self.reporter.report_dir / "evidence" / filename + path.write_text(text, encoding="utf-8") + self.reporter.add_evidence("目标机清单", filename, path) + if filename == "lsusb-tree.txt": + self.lsusb_tree = out + self.reporter.add("target_inventory", "PASS", "collected lsusb/system evidence") + + def configure_hid_and_msd(self, devices: dict[str, Any]) -> None: + udc = devices.get("udc", []) + serial = devices.get("serial", []) + if udc: + udc_name = udc[0]["name"] + self.api.patch( + "/config/hid", + { + "backend": "otg", + "otg_udc": udc_name, + "otg_profile": "full", + "otg_endpoint_budget": "auto", + "otg_keyboard_leds": False, + "mouse_absolute": True, + }, + ) + self.selected_hid_backend = "otg" + try: + self.api.patch("/config/msd", {"enabled": True}) + except Exception as exc: + if not self.msd_available(): + self.reporter.add("hid_msd_config", "WARN", f"configured OTG HID but failed to enable MSD: {exc}", udc=udc_name) + return + self.reporter.add("hid_msd_config", "PASS", "configured OTG HID and enabled MSD", udc=udc_name) + if not self.args.no_ventoy_sync: + try: + self.sync_ventoy_resources() + except Exception as exc: + self.reporter.add("ventoy_resources", "WARN", f"failed to sync Ventoy resources: {exc}") + if not self.args.no_msd_restart_after_enable: + try: + self.restart_target_service("msd_restart", "restarted One-KVM after enabling MSD") + except Exception as exc: + self.reporter.add("msd_restart", "WARN", f"failed to restart after enabling MSD: {exc}") + return + if serial: + port = serial[0]["path"] + self.api.patch( + "/config/hid", + { + "backend": "ch9329", + "ch9329_port": port, + "ch9329_baudrate": 9600, + "mouse_absolute": True, + }, + ) + self.selected_hid_backend = "ch9329" + self.reporter.add("hid_msd_config", "WARN", "configured CH9329 HID; MSD requires OTG and is skipped", port=port) + return + self.selected_hid_backend = "none" + self.reporter.add("hid_msd_config", "FAIL", "no UDC or CH9329 serial HID device found") + + def msd_available(self) -> bool: + try: + status = self.api.get("/msd/status") + except Exception: + return False + state = status.get("state") if isinstance(status, dict) else None + return bool( + isinstance(status, dict) + and ( + status.get("available") + or (isinstance(state, dict) and state.get("available")) + ) + ) + + def restart_target_service(self, check_name: str, detail: str) -> None: + if not self.ssh_password and self.args.ssh_password_prompt: + self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ") + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + code, out, err = ssh.run("systemctl restart one-kvm || service one-kvm restart", timeout=90) + if code != 0: + raise RuntimeError(err or out or "service restart command failed") + self.api.wait_health(timeout=self.args.health_timeout) + self.authenticate("login_after_restart", "authenticated after One-KVM restart") + self.reporter.add(check_name, "PASS", detail) + + def sync_ventoy_resources(self) -> None: + source_dir = Path(self.args.ventoy_resources_dir) if self.args.ventoy_resources_dir else default_ventoy_resources_dir() + if not source_dir.exists(): + self.reporter.add("ventoy_resources", "WARN", f"local Ventoy resources not found: {source_dir}") + return + + remote_dir = self.args.data_dir.rstrip("/") + "/ventoy" + required = ("boot.img", "core.img", "ventoy.disk.img") + test_cmd = " && ".join(f"test -s {shell_arg(remote_dir + '/' + name)}" for name in required) + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + code, _, _ = ssh.run(test_cmd, timeout=20) + if code == 0: + self.reporter.add("ventoy_resources", "PASS", "Ventoy resources already present on target", path=remote_dir) + return + + code, out, err = ssh.run(f"mkdir -p {shell_arg(remote_dir)}", timeout=30) + if code != 0: + raise RuntimeError(err or out or f"failed to create {remote_dir}") + + client = ssh.connect() + try: + sftp = client.open_sftp() + try: + for name in required: + local_plain = source_dir / name + local_xz = source_dir / f"{name}.xz" + remote_path = remote_dir + "/" + name + if local_plain.exists(): + sftp.put(str(local_plain), remote_path) + elif local_xz.exists(): + with lzma.open(local_xz, "rb") as src, sftp.open(remote_path, "wb") as dst: + while True: + chunk = src.read(1024 * 1024) + if not chunk: + break + dst.write(chunk) + else: + raise FileNotFoundError(f"{local_plain} or {local_xz}") + finally: + sftp.close() + finally: + client.close() + self.reporter.add("ventoy_resources", "PASS", "synced Ventoy resources to target", source=str(source_dir), path=remote_dir) + + def select_video_cases(self, devices: dict[str, Any]) -> list[VideoInputCase]: + selector = DeviceSelector(self.lsusb_tree, devices) + cases = selector.select() + if not cases: + self.reporter.add("video_input_select", "FAIL", "no suitable video input case found", devices=devices.get("video", [])) + return [] + self.reporter.add("video_input_select", "PASS", "selected video test cases", cases=[c.__dict__ for c in cases]) + return cases + + async def wait_for_agent_if_requested(self) -> None: + if not self.agent: + self.reporter.add("windows_agent", "SKIP", "agent host not configured") + return + if not await self.agent.wait_connected(timeout=self.args.agent_timeout): + self.reporter.add("windows_agent", "FAIL", "Windows agent did not connect before timeout") + + async def capture_video_screenshot(self, output_mode: str) -> None: + titles = { + "mjpeg": "MJPEG 模式网页截图", + "h264": "H264 WebRTC 模式网页截图", + "h265": "H265 WebRTC 尝试网页截图", + } + await self.capture_key_screenshot( + f"video_{output_mode}", + titles.get(output_mode, f"{output_mode} 网页截图"), + wait_video=True, + ) + + async def capture_key_screenshot(self, key: str, title: str, path: str = "/", wait_video: bool = False) -> None: + if self.args.no_screenshots or key in self.screenshot_keys: + return + self.screenshot_keys.add(key) + try: + from playwright.async_api import async_playwright + except ImportError: + self.reporter.add("screenshot", "WARN", "playwright is not installed; screenshot skipped", key=key) + return + + output_dir = self.reporter.report_dir / "evidence" / "screenshots" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{key}.png" + try: + async with async_playwright() as p: + browser = await p.chromium.launch(**self.chromium_launch_kwargs()) + context = await browser.new_context(ignore_https_errors=True, viewport={"width": 1440, "height": 960}) + for part in self.api.cookie_header().split("; "): + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + await context.add_cookies([{"name": name, "value": value, "url": self.api.base}]) + page = await context.new_page() + await page.goto(self.api.base + path, wait_until="domcontentloaded", timeout=15000) + if wait_video: + await self.wait_for_video_screenshot_ready(page) + else: + await page.wait_for_timeout(1500) + await page.screenshot(path=str(output_path), full_page=True) + await browser.close() + self.reporter.add_evidence("网页截图", title, output_path) + except Exception as exc: + if is_playwright_runtime_error(str(exc).lower()): + self.reporter.add("screenshot", "WARN", f"Playwright {CHROME_BROWSER_NAME} cannot start; screenshot skipped: {exc}", key=key) + return + self.reporter.add("screenshot", "WARN", f"failed to capture screenshot {key}: {exc}", key=key) + + async def wait_for_video_screenshot_ready(self, page: Any) -> None: + timeout = max(0, int(self.args.screenshot_wait_ms)) + if timeout <= 0: + return + try: + await page.wait_for_function(VIDEO_SCREENSHOT_READY_JS, timeout=timeout) + except Exception: + await page.wait_for_timeout(min(timeout, 1500)) + + def chromium_launch_kwargs(self) -> dict[str, Any]: + return { + "headless": True, + "args": chromium_launch_args(), + "channel": CHROME_BROWSER_CHANNEL, + } + + async def run_video_matrix(self, cases: list[VideoInputCase]) -> None: + if not cases: + return + codecs = self.available_codecs() + for case in cases: + self.configure_video_case(case) + for output_mode in ("mjpeg", "h264", "h265"): + if output_mode in ("h264", "h265") and output_mode not in codecs: + self.reporter.add( + f"video_{case.label}_{output_mode}", + "SKIP", + f"{output_mode} is not available", + case=case.__dict__, + ) + continue + try: + self.apply_video_case(case) + if output_mode == "mjpeg": + motion_started = await self.start_mjpeg_motion() + try: + result = self.measure_mjpeg(case) + if motion_started: + result["dynamic_source_fps"] = self.args.mjpeg_motion_fps + finally: + if motion_started: + await self.stop_mjpeg_motion() + else: + result = await self.measure_webrtc(case, output_mode) + status = self.video_result_status(result) + self.reporter.add(f"video_{case.label}_{output_mode}", status, result.get("message", ""), **result) + await self.capture_video_screenshot(output_mode) + except Exception as exc: + status = "SKIP" if self.is_environment_skip(exc, output_mode) else "FAIL" + self.reporter.add(f"video_{case.label}_{output_mode}", status, str(exc), case=case.__dict__) + await self.capture_video_screenshot(output_mode) + + async def start_mjpeg_motion(self) -> bool: + if not self.agent: + self.reporter.add("mjpeg_motion_source", "WARN", "Windows agent not connected; MJPEG fps may be affected by static-frame suppression") + return False + try: + await self.agent.command("start_dynamic", {"fps": self.args.mjpeg_motion_fps}, timeout=5) + return True + except Exception as exc: + self.reporter.add("mjpeg_motion_source", "WARN", f"failed to start dynamic MJPEG source: {exc}") + return False + + async def stop_mjpeg_motion(self) -> None: + if not self.agent: + return + try: + await self.agent.command("stop_dynamic", {}, timeout=5) + except Exception as exc: + self.reporter.add("mjpeg_motion_source", "WARN", f"failed to stop dynamic MJPEG source: {exc}") + + def video_result_status(self, result: dict[str, Any]) -> str: + if result.get("unsupported"): + return "SKIP" + if result.get("ok"): + return "PASS" + return "FAIL" + + @staticmethod + def is_environment_skip(exc: Exception, output_mode: str) -> bool: + text = str(exc).lower() + if is_playwright_runtime_error(text): + return True + return output_mode == "h265" and is_webrtc_codec_unsupported_error(text) + + def set_stream_mode(self, mode: str, timeout: int = 45) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last: Any = None + while time.monotonic() < deadline: + response = self.api.client.post("/api/stream/mode", json={"mode": mode}) + data = response.json() + last = data + if response.status_code >= 400: + raise RuntimeError(f"POST /stream/mode -> HTTP {response.status_code}: {data}") + if data.get("success") is False and not data.get("switching"): + raise RuntimeError(f"POST /stream/mode failed: {data}") + ready = self.wait_stream_mode_ready(mode, timeout=max(1, int(deadline - time.monotonic()))) + if ready: + return data + time.sleep(0.5) + raise TimeoutError(f"stream mode {mode} did not become ready; last={last}") + + def wait_stream_mode_ready(self, mode: str, timeout: int = 45) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + data = self.api.get("/stream/mode") + current = str(data.get("mode", "")).lower() + switching = bool(data.get("switching")) + if not switching and (current == mode or (mode == "webrtc" and current in {"h264", "h265", "vp8", "vp9"})): + return True + except Exception: + pass + time.sleep(0.5) + return False + + def available_codecs(self) -> set[str]: + try: + data = self.api.get("/stream/codecs") + return {c["id"] for c in data.get("codecs", []) if c.get("available")} + except Exception as exc: + self.reporter.add("stream_codecs", "WARN", f"failed to list codecs: {exc}") + return {"mjpeg", "h264"} + + def apply_video_case(self, case: VideoInputCase) -> None: + self.api.patch( + "/config/video", + { + "device": case.device, + "format": case.fmt, + "width": case.width, + "height": case.height, + "fps": int(round(case.fps)), + "quality": self.args.jpeg_quality, + }, + ) + + def configure_video_case(self, case: VideoInputCase) -> None: + self.apply_video_case(case) + self.reporter.add( + f"config_video_{case.label}", + "PASS", + f"{case.device} {case.fmt} {case.width}x{case.height}@{case.fps}", + case=case.__dict__, + ) + + def measure_mjpeg(self, case: VideoInputCase) -> dict[str, Any]: + self.set_stream_mode("mjpeg") + # One-KVM may prefer MJPEG capture when entering MJPEG/HTTP mode. Re-apply + # the requested input after the mode switch so YUYV/NV12 cases measure + # the intended capture format instead of the automatic MJPEG fallback. + self.apply_video_case(case) + self.api.post("/stream/start", {}) + client_id = f"test-{self.run_id}-{case.label}" + deadline = time.monotonic() + self.args.sample_seconds + frame_count = 0 + byte_count = 0 + first_frame_s: float | None = None + for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds, video_case=case): + now = time.monotonic() + if now >= deadline: + break + if first_frame_s is None: + first_frame_s = frame_time + frame_count += 1 + byte_count += len(frame) + if frame_count == 0: + raise RuntimeError("MJPEG stream did not become available before sample timeout") + duration = self.args.sample_seconds + measured_fps = frame_count / duration if duration else 0.0 + self.reporter.metric("mjpeg_fps", round(measured_fps, 2), "fps", case=case.label) + self.reporter.metric("mjpeg_bytes", byte_count, "bytes", case=case.label) + min_fps = min(case.fps * 0.8, case.fps - 1) if case.fps >= 5 else case.fps * 0.8 + functional = frame_count > 0 + degraded = self.args.strict_performance and functional and measured_fps < min_fps + message = f"MJPEG {measured_fps:.1f} fps over {duration}s" + if degraded: + message += f" (below expected {min_fps:.1f} fps)" + result = { + "ok": functional and not degraded, + "degraded": degraded, + "message": message, + "case": case.__dict__, + "frames": frame_count, + "fps": measured_fps, + "input_requested_fps": case.fps, + "first_frame_ms": None if first_frame_s is None else max(0, (first_frame_s - (deadline - duration)) * 1000), + "bytes": byte_count, + } + if self.args.strict_performance: + result["min_expected_fps"] = min_fps + return result + + async def measure_webrtc(self, case: VideoInputCase, codec: str) -> dict[str, Any]: + self.set_stream_mode(codec) + try: + from playwright.async_api import async_playwright + except ImportError: + return {"ok": False, "message": "playwright is not installed", "case": case.__dict__, "codec": codec} + + cookie_header = self.api.cookie_header() + js = WEBRTC_MEASURE_JS + async with async_playwright() as p: + try: + browser = await p.chromium.launch(**self.chromium_launch_kwargs()) + except Exception as exc: + text = str(exc).lower() + if is_playwright_runtime_error(text): + return { + "ok": False, + "unsupported": True, + "message": f"Playwright {CHROME_BROWSER_NAME} cannot start; run: {CHROME_INSTALL_COMMAND}", + "case": case.__dict__, + "codec": codec, + } + raise + context = await browser.new_context(ignore_https_errors=True) + for part in cookie_header.split("; "): + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + await context.add_cookies([{"name": name, "value": value, "url": self.api.base}]) + try: + page = await context.new_page() + await page.goto(self.api.base) + result = await page.evaluate(js, {"seconds": self.args.sample_seconds}) + except Exception as exc: + text = str(exc) + if codec == "h265" and is_webrtc_codec_unsupported_error(text): + return { + "ok": False, + "unsupported": True, + "message": "H.265 WebRTC appears unsupported by browser or streamer", + "case": case.__dict__, + "codec": codec, + "error": text, + } + raise + finally: + await browser.close() + fps = float(result.get("avgFps") or 0) + rtt_ms = float(result.get("maxRtt") or 0) * 1000 + jitter_ms = float(result.get("maxJitter") or 0) * 1000 + self.reporter.metric("webrtc_fps", round(fps, 2), "fps", case=case.label, codec=codec) + self.reporter.metric("webrtc_rtt_max", round(rtt_ms, 2), "ms", case=case.label, codec=codec) + self.reporter.metric("webrtc_jitter_max", round(jitter_ms, 2), "ms", case=case.label, codec=codec) + unsupported = codec == "h265" and int(result.get("framesDecoded") or 0) == 0 and fps == 0 + min_fps = max(1.0, min(case.fps * 0.75, case.fps - 2)) + functional = bool(result.get("connected")) and fps > 0 + degraded = self.args.strict_performance and functional and fps < min_fps + ok = (not unsupported) and functional and not degraded + message = "H.265 WebRTC appears unsupported by browser or decoder" if unsupported else f"{codec} WebRTC avg {fps:.1f} fps, max RTT {rtt_ms:.1f} ms" + if degraded: + message += f" (below expected {min_fps:.1f} fps)" + measured = { + "ok": bool(ok), + "unsupported": unsupported, + "degraded": degraded, + "message": message, + "case": case.__dict__, + "codec": codec, + "input_requested_fps": case.fps, + **result, + } + if self.args.strict_performance: + measured["min_expected_fps"] = min_fps + return measured + + async def run_hdmi_capture_tests(self, cases: list[VideoInputCase]) -> None: + if self.args.no_hdmi_tests: + return + if not self.agent: + for name in ("hdmi_identity", "hdmi_color_range"): + self.reporter.add(name, "SKIP", "Windows agent not connected") + for case in cases: + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + self.reporter.add( + self.video_latency_check_name(case, output_mode), + "SKIP", + "Windows agent not connected", + video_case=case.__dict__, + output_mode=output_mode, + ) + return + case = self.pick_hdmi_case(cases) + if not case: + for name in ("hdmi_identity", "hdmi_color_range"): + self.reporter.add(name, "SKIP", "no video input case available") + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + self.reporter.add(f"video_latency_{output_mode}", "SKIP", "no video input case available", output_mode=output_mode) + return + + try: + self.configure_hdmi_probe_case(case) + except Exception as exc: + for name in ("hdmi_identity", "hdmi_color_range"): + self.reporter.add(name, "FAIL", f"failed to configure HDMI probe video case: {exc}") + for latency_case in cases: + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + self.reporter.add( + self.video_latency_check_name(latency_case, output_mode), + "FAIL", + f"failed to configure video case before latency test: {exc}", + video_case=latency_case.__dict__, + output_mode=output_mode, + ) + return + + try: + await self.run_hdmi_color_test(case) + except Exception as exc: + self.reporter.add("hdmi_identity", "FAIL", str(exc)) + self.reporter.add("hdmi_color_range", "FAIL", str(exc)) + + codecs = self.available_codecs() + for latency_case in cases: + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + check_name = self.video_latency_check_name(latency_case, output_mode) + if output_mode in ("h264", "h265") and output_mode not in codecs: + self.reporter.add( + check_name, + "SKIP", + f"{output_mode} is not available", + video_case=latency_case.__dict__, + output_mode=output_mode, + ) + continue + try: + self.apply_video_case(latency_case) + if output_mode == "mjpeg": + await self.run_mjpeg_latency_test(latency_case, check_name=check_name, save_evidence=False) + else: + await self.run_webrtc_latency_test(latency_case, output_mode, check_name=check_name) + except Exception as exc: + status = "SKIP" if self.is_environment_skip(exc, output_mode) else "FAIL" + self.reporter.add(check_name, status, str(exc), video_case=latency_case.__dict__, output_mode=output_mode) + + @staticmethod + def pick_hdmi_case(cases: list[VideoInputCase]) -> VideoInputCase | None: + if not cases: + return None + for case in cases: + if case.fmt.upper() == "MJPEG" and case.width == 1920 and case.height == 1080: + return case + for case in cases: + if case.fmt.upper() == "MJPEG": + return case + return cases[0] + + def configure_hdmi_probe_case(self, case: VideoInputCase) -> None: + self.api.patch( + "/config/video", + { + "device": case.device, + "format": case.fmt, + "width": case.width, + "height": case.height, + "fps": int(round(case.fps)), + "quality": self.args.jpeg_quality, + }, + ) + self.reporter.add( + "config_video_hdmi_probe", + "PASS", + f"{case.device} {case.fmt} {case.width}x{case.height}@{case.fps}", + case=case.__dict__, + ) + + @staticmethod + def video_latency_check_name(case: VideoInputCase, output_mode: str) -> str: + return f"video_latency_{safe_filename(case.label)}_{safe_filename(output_mode)}" + + async def run_hdmi_color_test(self, case: VideoInputCase) -> None: + samples: list[dict[str, Any]] = [] + for name, color in HDMI_COLOR_SEQUENCE: + await self.agent.command("show", {"color": color, "full": True}, timeout=5) + await asyncio.sleep(self.args.hdmi_settle_ms / 1000) + expected = hex_to_rgb(color) + stats = self.capture_mjpeg_rgb_mean( + f"hdmi_color_{name}", + timeout=self.args.hdmi_capture_timeout, + evidence_title=f"HDMI 纯色采集帧 {name}", + expected_rgb=expected, + threshold=self.args.hdmi_color_fail, + video_case=case, + ) + err = rgb_error(expected, stats["mean_rgb"]) + closest_name, closest_error = closest_hdmi_color(stats["mean_rgb"]) + sample = { + "name": name, + "expected_hex": color, + "expected_rgb": expected, + "measured_rgb_mean": stats["mean_rgb"], + "measured_rgb_stddev": stats["stddev_rgb"], + "width": stats["width"], + "height": stats["height"], + "mean_abs_error": err["mean_abs_error"], + "max_abs_error": err["max_abs_error"], + "closest_color": closest_name, + "closest_error": closest_error, + "target_detected": bool(stats.get("target_detected")), + "frames_seen": int(stats.get("frames_seen") or 0), + } + sample["identity_match"] = self.hdmi_identity_match(sample) + samples.append(sample) + self.reporter.metric("hdmi_color_mean_abs_error", round(err["mean_abs_error"], 2), "rgb_level", color=name) + self.reporter.metric("hdmi_color_max_abs_error", round(err["max_abs_error"], 2), "rgb_level", color=name) + + identity_colors = [s for s in samples if s["name"] in {"red", "green", "blue"}] + identity_ok = bool(identity_colors) and all(bool(s["identity_match"]) for s in identity_colors) + identity_status = "PASS" if identity_ok else "FAIL" + self.reporter.add( + "hdmi_identity", + identity_status, + "HDMI capture matches Windows fullscreen color sequence" if identity_ok else "HDMI capture did not match Windows fullscreen color sequence", + samples=identity_colors, + video_case=case.__dict__, + ) + + max_mean_error = max((float(s["mean_abs_error"]) for s in samples), default=999.0) + max_abs_error = max((float(s["max_abs_error"]) for s in samples), default=999.0) + if max_mean_error <= self.args.hdmi_color_warn: + color_status = "PASS" + elif max_mean_error <= self.args.hdmi_color_fail: + color_status = "WARN" + else: + color_status = "FAIL" + self.reporter.add( + "hdmi_color_range", + color_status, + f"max mean RGB error {max_mean_error:.1f}, max channel error {max_abs_error:.1f}", + samples=samples, + warn_threshold=self.args.hdmi_color_warn, + fail_threshold=self.args.hdmi_color_fail, + video_case=case.__dict__, + ) + + @staticmethod + def hdmi_identity_match(sample: dict[str, Any]) -> bool: + name = str(sample["name"]) + mean = tuple(float(x) for x in sample["measured_rgb_mean"]) + if name == "red": + return mean[0] > 120 and mean[0] > mean[1] * 1.8 and mean[0] > mean[2] * 1.8 + if name == "green": + return mean[1] > 120 and mean[1] > mean[0] * 1.8 and mean[1] > mean[2] * 1.8 + if name == "blue": + return mean[2] > 120 and mean[2] > mean[0] * 1.8 and mean[2] > mean[1] * 1.8 + return float(sample["mean_abs_error"]) <= 60 + + async def run_mjpeg_latency_test(self, case: VideoInputCase, check_name: str = "hdmi_latency_mjpeg", save_evidence: bool = True) -> None: + if self.args.hdmi_latency_trials <= 0: + self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode="mjpeg") + return + offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt") + trials: list[dict[str, Any]] = [] + colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")] + for i in range(self.args.hdmi_latency_trials): + source, target = colors[i % len(colors)] + await self.agent.command("show", {"color": source, "full": True}, timeout=5) + await asyncio.sleep(self.args.hdmi_settle_ms / 1000) + await self.agent.command("schedule_color", {"color": target, "delay_ms": self.args.hdmi_latency_delay_ms}, timeout=5) + detect_timeout = (self.args.hdmi_latency_delay_ms + self.args.hdmi_latency_timeout_ms) / 1000 + detected = self.detect_mjpeg_color( + hex_to_rgb(target), + f"{check_name}_{i}", + timeout=detect_timeout, + threshold=self.args.hdmi_color_fail, + evidence_title=f"HDMI 延迟命中帧 {case.label} #{i + 1}" if save_evidence else None, + video_case=case, + ) + display = await self.agent.command("display_state", {}, timeout=5) + actual_agent_ns = int(display.get("last_change_unix_nano") or 0) + actual_linux_ns = actual_agent_ns - offset_ns + latency_ms = (int(detected["wall_ns"]) - actual_linux_ns) / 1_000_000 + trial = { + "trial": i + 1, + "source": source, + "target": target, + "latency_ms": latency_ms, + "detected_rgb_mean": detected["mean_rgb"], + "mean_abs_error": detected["mean_abs_error"], + "detected_wall_ns": detected["wall_ns"], + "agent_change_unix_nano": actual_agent_ns, + "clock_sync": sync, + } + trials.append(trial) + self.reporter.metric(check_name, round(latency_ms, 2), "ms", trial=i + 1, target=target, case=case.label) + + latencies = [float(t["latency_ms"]) for t in trials] + p50 = percentile(latencies, 50) + p95 = percentile(latencies, 95) + max_latency = max(latencies) if latencies else 0.0 + self.reporter.metric(f"{check_name}_p50", round(p50, 2), "ms", case=case.label) + self.reporter.metric(f"{check_name}_p95", round(p95, 2), "ms", case=case.label) + self.reporter.metric(f"{check_name}_max", round(max_latency, 2), "ms", case=case.label) + if not trials: + status = "FAIL" + elif p95 <= self.args.hdmi_latency_fail_ms: + status = "PASS" + else: + status = "FAIL" + self.reporter.add( + check_name, + status, + f"MJPEG visual latency for {case.label}: p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms", + trials=trials, + p50_ms=p50, + p95_ms=p95, + max_ms=max_latency, + functional_threshold_ms=self.args.hdmi_latency_fail_ms, + video_case=case.__dict__, + output_mode="mjpeg", + ) + + async def run_webrtc_latency_test(self, case: VideoInputCase, output_mode: str, check_name: str) -> None: + if self.args.hdmi_latency_trials <= 0: + self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode=output_mode) + return + self.set_stream_mode(output_mode) + self.apply_video_case(case) + try: + from playwright.async_api import async_playwright + except ImportError as exc: + raise RuntimeError("playwright is required for WebRTC visual latency tests; run pip install -r requirements.txt") from exc + + offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt") + cookie_header = self.api.cookie_header() + trials: list[dict[str, Any]] = [] + colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")] + async with async_playwright() as p: + try: + browser = await p.chromium.launch(**self.chromium_launch_kwargs()) + except Exception as exc: + text = str(exc).lower() + if is_playwright_runtime_error(text): + raise RuntimeError(f"Playwright {CHROME_BROWSER_NAME} cannot start; run: {CHROME_INSTALL_COMMAND}") from exc + raise + context = await browser.new_context(ignore_https_errors=True) + for part in cookie_header.split("; "): + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + await context.add_cookies([{"name": name, "value": value, "url": self.api.base}]) + page = await context.new_page() + try: + await page.goto(self.api.base, wait_until="domcontentloaded", timeout=15000) + setup = await page.evaluate(WEBRTC_LATENCY_SETUP_JS, {"timeoutMs": 15000}) + if not setup.get("connected"): + raise RuntimeError(f"{output_mode} WebRTC did not connect: {setup}") + for i in range(self.args.hdmi_latency_trials): + source, target = colors[i % len(colors)] + await self.agent.command("show", {"color": source, "full": True}, timeout=5) + await asyncio.sleep(self.args.hdmi_settle_ms / 1000) + source_seen = await page.evaluate( + WEBRTC_COLOR_DETECT_JS, + { + "targetRgb": hex_to_rgb(source), + "timeoutMs": max(1000, self.args.hdmi_settle_ms + 2000), + "threshold": self.args.hdmi_color_fail, + }, + ) + if not source_seen.get("target_detected"): + raise RuntimeError(f"{output_mode} WebRTC did not show source color before latency trial: {source_seen}") + + detect_timeout_ms = self.args.hdmi_latency_delay_ms + self.args.hdmi_latency_timeout_ms + detect_task = asyncio.create_task( + page.evaluate( + WEBRTC_COLOR_DETECT_JS, + { + "targetRgb": hex_to_rgb(target), + "timeoutMs": detect_timeout_ms, + "threshold": self.args.hdmi_color_fail, + }, + ) + ) + await self.agent.command("schedule_color", {"color": target, "delay_ms": self.args.hdmi_latency_delay_ms}, timeout=5) + detected = await detect_task + if not detected.get("target_detected"): + raise RuntimeError(f"{output_mode} WebRTC target color not detected before timeout: {detected}") + display = await self.agent.command("display_state", {}, timeout=5) + actual_agent_ns = int(display.get("last_change_unix_nano") or 0) + actual_linux_ns = actual_agent_ns - offset_ns + latency_ms = (int(detected["wall_ns"]) - actual_linux_ns) / 1_000_000 + trial = { + "trial": i + 1, + "source": source, + "target": target, + "latency_ms": latency_ms, + "detected_rgb_mean": detected["mean_rgb"], + "mean_abs_error": detected["mean_abs_error"], + "detected_wall_ns": detected["wall_ns"], + "agent_change_unix_nano": actual_agent_ns, + "clock_sync": sync, + } + trials.append(trial) + self.reporter.metric(check_name, round(latency_ms, 2), "ms", trial=i + 1, target=target, case=case.label, codec=output_mode) + finally: + try: + await page.evaluate(WEBRTC_LATENCY_CLOSE_JS) + except Exception: + pass + await browser.close() + + latencies = [float(t["latency_ms"]) for t in trials] + p50 = percentile(latencies, 50) + p95 = percentile(latencies, 95) + max_latency = max(latencies) if latencies else 0.0 + self.reporter.metric(f"{check_name}_p50", round(p50, 2), "ms", case=case.label, codec=output_mode) + self.reporter.metric(f"{check_name}_p95", round(p95, 2), "ms", case=case.label, codec=output_mode) + self.reporter.metric(f"{check_name}_max", round(max_latency, 2), "ms", case=case.label, codec=output_mode) + status = "PASS" if trials and p95 <= self.args.hdmi_latency_fail_ms else "FAIL" + self.reporter.add( + check_name, + status, + f"{output_mode} visual latency for {case.label}: p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms", + trials=trials, + p50_ms=p50, + p95_ms=p95, + max_ms=max_latency, + functional_threshold_ms=self.args.hdmi_latency_fail_ms, + video_case=case.__dict__, + output_mode=output_mode, + ) + + async def sync_agent_clock(self, metric_name: str = "agent_clock_sync_rtt") -> tuple[int, dict[str, Any]]: + best: tuple[int, int, dict[str, Any]] | None = None + for _ in range(7): + t0 = time.time_ns() + payload = await self.agent.command("ping", {}, timeout=5) + t1 = time.time_ns() + rtt = t1 - t0 + midpoint = (t0 + t1) // 2 + offset = int(payload.get("unix_nano") or 0) - midpoint + detail = { + "offset_ns": offset, + "rtt_ns": rtt, + "offset_ms": offset / 1_000_000, + "rtt_ms": rtt / 1_000_000, + } + if best is None or rtt < best[0]: + best = (rtt, offset, detail) + await asyncio.sleep(0.05) + assert best is not None + self.reporter.metric(metric_name, round(best[2]["rtt_ms"], 3), "ms") + return best[1], best[2] + + def capture_mjpeg_rgb_mean( + self, + client_label: str, + timeout: float = 6.0, + evidence_title: str | None = None, + expected_rgb: tuple[int, int, int] | None = None, + threshold: float | None = None, + video_case: VideoInputCase | None = None, + ) -> dict[str, Any]: + threshold = self.args.hdmi_color_fail if threshold is None else threshold + required_matches = max(1, int(self.args.hdmi_match_frames)) + best: dict[str, Any] | None = None + best_frame: bytes | None = None + last: dict[str, Any] | None = None + last_frame: bytes | None = None + consecutive_matches = 0 + frames_seen = 0 + + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + frames_seen += 1 + stats = jpeg_rgb_stats(frame) + stats["wall_ns"] = wall_ns + stats["frames_seen"] = frames_seen + last = stats + last_frame = frame + if expected_rgb is None: + stats["target_detected"] = True + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, frame) + return stats + + err = rgb_error(expected_rgb, stats["mean_rgb"]) + stats.update(err) + if best is None or float(err["mean_abs_error"]) < float(best.get("mean_abs_error", 999.0)): + best = dict(stats) + best_frame = frame + + if float(err["mean_abs_error"]) <= threshold: + consecutive_matches += 1 + if consecutive_matches >= required_matches: + stats["target_detected"] = True + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, frame) + return stats + else: + consecutive_matches = 0 + + chosen = best or last + chosen_frame = best_frame or last_frame + if chosen is not None and chosen_frame is not None: + chosen["target_detected"] = False + chosen["frames_seen"] = frames_seen + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, chosen_frame) + return chosen + raise RuntimeError(f"no MJPEG frame captured for {client_label}") + + def detect_mjpeg_color( + self, + expected_rgb: tuple[int, int, int], + client_label: str, + timeout: float, + threshold: float, + evidence_title: str | None = None, + video_case: VideoInputCase | None = None, + ) -> dict[str, Any]: + last: dict[str, Any] | None = None + last_frame: bytes | None = None + frames_seen = 0 + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + frames_seen += 1 + stats = jpeg_rgb_stats(frame) + err = rgb_error(expected_rgb, stats["mean_rgb"]) + stats.update(err) + stats["wall_ns"] = wall_ns + stats["frames_seen"] = frames_seen + last = stats + last_frame = frame + if float(err["mean_abs_error"]) <= threshold: + stats["target_detected"] = True + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, frame) + return stats + if last_frame is not None and evidence_title: + self.save_frame_evidence(f"{client_label}_last", f"{evidence_title}(未命中末帧)", last_frame) + raise RuntimeError(f"target HDMI color not detected before timeout; last={last}") + + def save_frame_evidence(self, label: str, title: str, frame: bytes) -> None: + output_dir = self.reporter.report_dir / "evidence" / "hdmi-frames" + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / f"{safe_filename(label)}.jpg" + path.write_bytes(frame) + self.reporter.add_evidence("HDMI 采集帧", title, path) + + def iter_mjpeg_frames(self, client_label: str, timeout: float, video_case: VideoInputCase | None = None): + self.set_stream_mode("mjpeg") + if video_case is not None: + self.apply_video_case(video_case) + self.api.post("/stream/start", {}) + client_id = f"test-{self.run_id}-{client_label}" + url = f"{self.api.base}/api/stream/mjpeg?client_id={client_id}" + deadline = time.monotonic() + timeout + buf = b"" + while time.monotonic() < deadline: + try: + stream_timeout = httpx.Timeout(timeout=max(1.0, timeout), connect=5.0, read=1.0, write=5.0, pool=5.0) + with self.api.client.stream("GET", url, timeout=stream_timeout) as response: + response.raise_for_status() + for chunk in response.iter_bytes(): + if time.monotonic() >= deadline: + return + if not chunk: + continue + buf += chunk + while True: + soi = buf.find(b"\xff\xd8") + if soi < 0: + buf = buf[-2:] + break + eoi = buf.find(b"\xff\xd9", soi + 2) + if eoi < 0: + buf = buf[soi:] + if len(buf) > 8 * 1024 * 1024: + buf = buf[-1024 * 1024:] + break + frame = buf[soi : eoi + 2] + buf = buf[eoi + 2 :] + yield frame, time.monotonic(), time.time_ns() + except httpx.ReadTimeout: + continue + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 503: + raise + time.sleep(0.5) + self.set_stream_mode("mjpeg", timeout=10) + if video_case is not None: + self.apply_video_case(video_case) + self.api.post("/stream/start", {}) + + async def run_hid_test(self) -> None: + try: + status = self.api.get("/hid/status") + except Exception as exc: + self.reporter.add("hid_status", "FAIL", str(exc)) + return + if not status.get("available"): + self.reporter.add("hid_status", "FAIL", "HID is not available", status=status) + return + self.reporter.add("hid_status", "PASS", "HID backend is available", status=status) + if not self.agent: + self.reporter.add("hid_input", "SKIP", "Windows agent not connected") + self.reporter.add("hid_latency", "SKIP", "Windows agent not connected") + return + try: + keyboard_events = await self.run_hid_keyboard_matrix() + keyboard = self.evaluate_hid_keyboard_events(keyboard_events) + mouse = await self.run_hid_mouse_matrix() + event_count = len(keyboard_events) + len(mouse.get("events", [])) + ok = bool(keyboard.get("ok")) and bool(mouse.get("ok")) + message = ( + "HID matrix passed: alphanumeric, function keys, safe combos, absolute/relative mouse" + if ok + else "HID matrix failed; see missing key/mouse details" + ) + sample_events = [*keyboard_events[:60], *(mouse.get("events", [])[:20])] + self.reporter.add( + "hid_input", + "PASS" if ok else "FAIL", + message, + event_count=event_count, + chars=keyboard.get("chars", ""), + keyboard=keyboard, + mouse={k: v for k, v in mouse.items() if k != "events"}, + events=sample_events, + ) + except Exception as exc: + self.reporter.add("hid_input", "FAIL", str(exc)) + try: + await self.run_hid_latency_test() + except Exception as exc: + self.reporter.add("hid_latency", "FAIL", str(exc)) + + async def connect_hid_websocket(self) -> Any: + try: + import websockets + except ImportError as exc: + raise RuntimeError("websockets is required for HID test") from exc + + headers = {"Cookie": self.api.cookie_header()} + uri = f"ws://{self.args.target}:{self.args.http_port}/api/ws/hid" + header_arg = "additional_headers" if "additional_headers" in inspect.signature(websockets.connect).parameters else "extra_headers" + connect_kwargs = {"max_size": 1024 * 1024, header_arg: headers} + ws = await websockets.connect(uri, **connect_kwargs) + initial = await ws.recv() + if isinstance(initial, str) or not initial or initial[0] != 0: + await ws.close() + raise RuntimeError(f"HID WebSocket unavailable: {initial!r}") + return ws + + async def run_hid_keyboard_matrix(self) -> list[dict[str, Any]]: + if not self.agent: + return [] + await self.agent.command("begin_hid_capture", {}, timeout=10) + await asyncio.sleep(0.3) + ws = await self.connect_hid_websocket() + try: + for ch in HID_ALNUM_TEXT: + usage, mod = HID_KEY_USAGE[ch] + await self.send_hid_key(ws, usage, mod) + for _, usage, _ in HID_FUNCTION_KEYS: + await self.send_hid_key(ws, usage, 0) + for _, usage, _, mod in HID_SAFE_COMBOS: + await self.send_hid_key(ws, usage, mod) + finally: + await ws.close() + await asyncio.sleep(1) + payload = await self.agent.command("get_hid_events", {}, timeout=10) + return payload.get("events", []) + + async def run_hid_mouse_matrix(self) -> dict[str, Any]: + if not self.agent: + return {"ok": False, "reason": "Windows agent not connected", "events": []} + + screen = ((self.agent.hello or {}).get("screen") or {}) if self.agent else {} + width = int(screen.get("width") or 0) + height = int(screen.get("height") or 0) + if width <= 0 or height <= 0: + return {"ok": False, "reason": "Windows agent did not report screen size", "events": []} + + await self.agent.command("begin_hid_capture", {}, timeout=10) + await asyncio.sleep(0.3) + ws = await self.connect_hid_websocket() + try: + await self.send_hid_mouse(ws, 0x01, 8192, 8192, 0) + await asyncio.sleep(0.2) + abs_x = 19660 + abs_y = 19660 + await self.send_hid_mouse(ws, 0x01, abs_x, abs_y, 0) + await asyncio.sleep(0.4) + payload = await self.agent.command("get_hid_events", {}, timeout=10) + base_events = payload.get("events", []) + base_move = self.last_event_of_type(base_events, "mouse_move") + + rel_dx = 32 + rel_dy = 24 + await self.send_hid_mouse(ws, 0x00, rel_dx, rel_dy, 0) + await asyncio.sleep(0.4) + payload = await self.agent.command("get_hid_events", {}, timeout=10) + events = payload.get("events", []) + finally: + await ws.close() + + target_x = self.hid_abs_to_pixel(abs_x, width) + target_y = self.hid_abs_to_pixel(abs_y, height) + abs_tolerance_x = max(100, int(width * 0.10)) + abs_tolerance_y = max(80, int(height * 0.10)) + abs_ok = bool( + base_move + and abs(int(base_move.get("x", -9999)) - target_x) <= abs_tolerance_x + and abs(int(base_move.get("y", -9999)) - target_y) <= abs_tolerance_y + ) + + relative_events = events[len(base_events) :] + rel_move = self.last_event_of_type(relative_events, "mouse_move") + rel_delta_x = int(rel_move.get("x", 0)) - int(base_move.get("x", 0)) if rel_move and base_move else 0 + rel_delta_y = int(rel_move.get("y", 0)) - int(base_move.get("y", 0)) if rel_move and base_move else 0 + rel_ok = bool( + base_move + and rel_move + and 2 <= rel_delta_x <= max(300, int(width * 0.25)) + and 2 <= rel_delta_y <= max(300, int(height * 0.25)) + ) + + return { + "ok": abs_ok and rel_ok, + "absolute_ok": abs_ok, + "relative_ok": rel_ok, + "screen": {"width": width, "height": height}, + "absolute_target": {"x": target_x, "y": target_y}, + "absolute_observed": {"x": base_move.get("x"), "y": base_move.get("y")} if base_move else None, + "relative_delta": {"x": rel_delta_x, "y": rel_delta_y}, + "events": events, + } + + async def run_hid_latency_test(self) -> None: + if not self.agent: + self.reporter.add("hid_latency", "SKIP", "Windows agent not connected") + return + trial_count = max(0, int(self.args.hid_latency_trials)) + if trial_count <= 0: + self.reporter.add("hid_latency", "SKIP", "HID latency trials disabled") + return + + key_name, usage, vk = HID_LATENCY_KEY + offset_ns, sync = await self.sync_agent_clock("hid_agent_clock_sync_rtt") + trials: list[dict[str, Any]] = [] + missing = 0 + ws = await self.connect_hid_websocket() + try: + for i in range(trial_count): + await self.agent.command("begin_hid_capture", {}, timeout=10) + await asyncio.sleep(0.1) + send_ns = time.time_ns() + await ws.send(bytes([0x01, 0x00, usage, 0x00])) + await asyncio.sleep(0.02) + await ws.send(bytes([0x01, 0x01, usage, 0x00])) + event = await self.wait_hid_key_down(vk, timeout=2.0) + if not event: + missing += 1 + continue + event_agent_ns = int(event.get("unix_nano") or 0) + event_linux_ns = event_agent_ns - offset_ns + raw_latency_ms = (event_linux_ns - send_ns) / 1_000_000 + latency_ms = max(0.0, raw_latency_ms) + trial = { + "trial": i + 1, + "key": key_name, + "latency_ms": latency_ms, + "raw_latency_ms": raw_latency_ms, + "sent_unix_nano": send_ns, + "event_agent_unix_nano": event_agent_ns, + "event_linux_unix_nano": event_linux_ns, + "clock_sync": sync, + } + trials.append(trial) + self.reporter.metric("hid_latency", round(latency_ms, 2), "ms", trial=i + 1, key=key_name) + await asyncio.sleep(0.08) + finally: + await ws.close() + + latencies = [float(t["latency_ms"]) for t in trials] + p50 = percentile(latencies, 50) + p95 = percentile(latencies, 95) + max_latency = max(latencies) if latencies else 0.0 + if latencies: + self.reporter.metric("hid_latency_p50", round(p50, 2), "ms", key=key_name) + self.reporter.metric("hid_latency_p95", round(p95, 2), "ms", key=key_name) + self.reporter.metric("hid_latency_max", round(max_latency, 2), "ms", key=key_name) + + if not trials: + status = "FAIL" + elif p95 <= self.args.hid_latency_warn_ms and missing == 0: + status = "PASS" + elif p95 <= self.args.hid_latency_fail_ms: + status = "WARN" + else: + status = "FAIL" + self.reporter.add( + "hid_latency", + status, + f"HID key latency p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms", + key=key_name, + trials=trials, + missing_trials=missing, + p50_ms=p50, + p95_ms=p95, + max_ms=max_latency, + warn_threshold_ms=self.args.hid_latency_warn_ms, + fail_threshold_ms=self.args.hid_latency_fail_ms, + ) + + async def wait_hid_key_down(self, vk: int, timeout: float) -> dict[str, Any] | None: + if not self.agent: + return None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + payload = await self.agent.command("get_hid_events", {}, timeout=10) + for event in payload.get("events", []): + if event.get("type") == "key_down" and int(event.get("code") or 0) == vk: + return event + await asyncio.sleep(0.05) + return None + + async def send_hid_sequence(self, text: str) -> None: + ws = await self.connect_hid_websocket() + try: + for ch in text: + if ch not in HID_KEY_USAGE: + continue + usage, mod = HID_KEY_USAGE[ch] + await self.send_hid_key(ws, usage, mod) + finally: + await ws.close() + + async def send_hid_key(self, ws: Any, usage: int, modifier: int = 0) -> None: + await ws.send(bytes([0x01, 0x00, usage, modifier])) + await asyncio.sleep(0.02) + await ws.send(bytes([0x01, 0x01, usage, modifier])) + await asyncio.sleep(0.02) + + async def send_hid_mouse(self, ws: Any, event_type: int, x: int, y: int, value: int) -> None: + await ws.send(bytes([0x02, event_type]) + struct.pack(" int: + return int(round(value * max(span - 1, 0) / 32767)) + + @staticmethod + def last_event_of_type(events: list[dict[str, Any]], event_type: str) -> dict[str, Any] | None: + for event in reversed(events): + if event.get("type") == event_type: + return event + return None + + @staticmethod + def evaluate_hid_keyboard_events(events: list[dict[str, Any]]) -> dict[str, Any]: + chars = "".join(e.get("char", "") for e in events if e.get("type") == "char") + down_codes = {int(e.get("code") or 0) for e in events if e.get("type") == "key_down"} + up_codes = {int(e.get("code") or 0) for e in events if e.get("type") == "key_up"} + + missing_alnum_down = [label for label, _, vk in HID_ALNUM_KEYS if vk not in down_codes] + missing_alnum_up = [label for label, _, vk in HID_ALNUM_KEYS if vk not in up_codes] + missing_functions_down = [label for label, _, vk in HID_FUNCTION_KEYS if vk not in down_codes] + missing_functions_up = [label for label, _, vk in HID_FUNCTION_KEYS if vk not in up_codes] + missing_combos = [ + name + for name, _, vk, mod in HID_SAFE_COMBOS + if not any( + e.get("type") == "key_down" + and int(e.get("code") or 0) == vk + and (int(e.get("modifiers") or 0) & mod) == mod + for e in events + ) + ] + + ok = not ( + missing_alnum_down + or missing_alnum_up + or missing_functions_down + or missing_functions_up + or missing_combos + ) + return { + "ok": ok, + "chars": chars, + "event_count": len(events), + "alphanumeric_tested": len(HID_ALNUM_KEYS), + "function_keys_tested": len(HID_FUNCTION_KEYS), + "safe_combos_tested": len(HID_SAFE_COMBOS), + "missing_alphanumeric_down": missing_alnum_down, + "missing_alphanumeric_up": missing_alnum_up, + "missing_function_down": missing_functions_down, + "missing_function_up": missing_functions_up, + "missing_combos": missing_combos, + } + + async def run_msd_test(self) -> None: + if self.selected_hid_backend != "otg": + self.reporter.add("msd", "SKIP", "MSD requires OTG HID backend") + return + if not self.agent: + self.reporter.add("msd", "SKIP", "Windows agent not connected") + return + try: + status = self.api.get("/msd/status") + if not status.get("available"): + self.reporter.add("msd", "FAIL", "MSD controller is unavailable", status=status) + return + snapshot = await self.agent.command("msd_snapshot", {}, timeout=10) + known = [d["root"] for d in snapshot.get("drives", [])] + self.api.post("/msd/drive/init", {"size_mb": self.args.msd_size_mb}) + self.api.post("/msd/connect", {"mode": "drive"}) + drive = await self.agent.command("msd_wait_new", {"known": known, "timeout_ms": 60000}, timeout=70) + root = drive.get("root") + verify = await self.agent.command( + "msd_write_read", + {"root": root, "filename": f"okvm-msd-{self.run_id}.bin", "size_bytes": self.args.msd_probe_bytes}, + timeout=60, + ) + if "write_mib_s" in verify: + self.reporter.metric("msd_write_mib_s", round(float(verify["write_mib_s"]), 2), "MiB/s") + if "read_mib_s" in verify: + self.reporter.metric("msd_read_mib_s", round(float(verify["read_mib_s"]), 2), "MiB/s") + if "cached_read_mib_s" in verify: + self.reporter.metric("msd_cached_read_mib_s", round(float(verify["cached_read_mib_s"]), 2), "MiB/s") + self.api.post("/msd/disconnect", {}) + await self.agent.command("msd_wait_removed", {"root": root, "timeout_ms": 60000}, timeout=70) + self.reporter.add("msd", "PASS", "Windows detected virtual drive and read/write verification passed", drive=drive, verify=verify) + except Exception as exc: + try: + self.api.post("/msd/disconnect", {}) + except Exception: + pass + status = "SKIP" if is_msd_environment_error(str(exc)) else "FAIL" + self.reporter.add("msd", status, str(exc)) + + def run_atx_api_test(self) -> None: + try: + status = self.api.get("/atx/status") + config = self.api.get("/config/atx") + wol = self.api.post("/atx/wol", {"mac_address": self.args.wol_mac}) + self.reporter.add("atx_api", "PASS", "ATX status/config/WOL APIs responded", status=status, config=config, wol=wol) + except Exception as exc: + self.reporter.add("atx_api", "FAIL", str(exc)) + + def collect_logs(self) -> None: + if not self.ssh_password: + return + try: + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + code, out, err = ssh.run("journalctl -u one-kvm -n 300 --no-pager || true", timeout=30) + path = self.reporter.report_dir / "evidence" / "journalctl-one-kvm-tail.txt" + path.write_text(out + err, encoding="utf-8") + self.reporter.add_evidence("日志", "one-kvm journal tail", path) + self.reporter.add("log_collection", "PASS", "collected one-kvm journal tail") + except Exception as exc: + self.reporter.add("log_collection", "WARN", str(exc)) + + +VIDEO_SCREENSHOT_READY_JS = r""" +() => { + const text = (document.body?.innerText || '').toLowerCase(); + if (text.includes('connection failed') || text.includes('operation failed')) { + return true; + } + if (text.includes('waiting for first frame') || text.includes('webrtc connected') || text.includes('please wait')) { + return false; + } + const media = Array.from(document.querySelectorAll('video, canvas, img')).filter((el) => { + const r = el.getBoundingClientRect(); + return r.width >= 320 && r.height >= 180 && r.bottom > 120; + }); + return media.length > 0; +} +""" + + +WEBRTC_MEASURE_JS = r""" +async ({seconds}) => { + const api = async (path, opts = {}) => { + const response = await fetch('/api' + path, { + credentials: 'include', + headers: {'Content-Type': 'application/json', ...(opts.headers || {})}, + ...opts + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(path + ' HTTP ' + response.status + ': ' + JSON.stringify(data)); + return data; + }; + + const ice = await api('/webrtc/ice-servers'); + const pc = new RTCPeerConnection({ + iceServers: (ice.ice_servers || []).map(s => ({urls: s.urls, username: s.username, credential: s.credential})) + }); + pc.addTransceiver('video', {direction: 'recvonly'}); + pc.addTransceiver('audio', {direction: 'recvonly'}); + pc.createDataChannel('hid', {ordered: true, maxRetransmits: 3}); + + let sessionId = null; + const pending = []; + pc.onicecandidate = (event) => { + if (!event.candidate) return; + const item = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + usernameFragment: event.candidate.usernameFragment + }; + if (sessionId) { + api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: item})}).catch(() => {}); + } else { + pending.push(item); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await api('/webrtc/offer', {method: 'POST', body: JSON.stringify({sdp: offer.sdp})}); + if (answer.success === false) { + throw new Error('webrtc_offer_failed: ' + (answer.message || JSON.stringify(answer))); + } + if (typeof answer.sdp !== 'string' || !answer.sdp.trim().startsWith('v=')) { + throw new Error('webrtc_offer_invalid_sdp: ' + JSON.stringify(answer).slice(0, 500)); + } + sessionId = answer.session_id; + await pc.setRemoteDescription({type: 'answer', sdp: answer.sdp}); + for (const c of answer.ice_candidates || []) { + try { await pc.addIceCandidate(c); } catch {} + } + for (const c of pending.splice(0)) { + await api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: c})}).catch(() => {}); + } + + const waitStart = performance.now(); + while (pc.connectionState !== 'connected' && performance.now() - waitStart < 12000) { + if (pc.connectionState === 'failed' || pc.connectionState === 'closed') break; + await new Promise(r => setTimeout(r, 100)); + } + + const samples = []; + const endAt = performance.now() + seconds * 1000; + while (performance.now() < endAt) { + const report = await pc.getStats(); + let sample = {fps: 0, framesDecoded: 0, framesDropped: 0, rtt: 0, jitter: 0, bytes: 0}; + report.forEach(stat => { + if (stat.type === 'inbound-rtp' && stat.kind === 'video') { + sample.fps = stat.framesPerSecond || 0; + sample.framesDecoded = stat.framesDecoded || 0; + sample.framesDropped = stat.framesDropped || 0; + sample.jitter = stat.jitter || 0; + sample.bytes = stat.bytesReceived || 0; + } + if (stat.type === 'candidate-pair' && (stat.nominated || stat.selected)) { + sample.rtt = stat.currentRoundTripTime || 0; + } + }); + samples.push(sample); + await new Promise(r => setTimeout(r, 1000)); + } + const fpsValues = samples.map(s => s.fps).filter(v => v > 0); + const avgFps = fpsValues.length ? fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length : 0; + const maxRtt = Math.max(0, ...samples.map(s => s.rtt || 0)); + const maxJitter = Math.max(0, ...samples.map(s => s.jitter || 0)); + const last = samples[samples.length - 1] || {}; + try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: sessionId})}); } catch {} + pc.close(); + return { + connected: pc.connectionState === 'connected' || samples.length > 0, + avgFps, + maxRtt, + maxJitter, + framesDecoded: last.framesDecoded || 0, + framesDropped: last.framesDropped || 0, + bytesReceived: last.bytes || 0, + samples + }; +} +""" + + +WEBRTC_LATENCY_SETUP_JS = r""" +async ({timeoutMs}) => { + const api = async (path, opts = {}) => { + const response = await fetch('/api' + path, { + credentials: 'include', + headers: {'Content-Type': 'application/json', ...(opts.headers || {})}, + ...opts + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(path + ' HTTP ' + response.status + ': ' + JSON.stringify(data)); + return data; + }; + + if (window.__okvmLatency) { + try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: window.__okvmLatency.sessionId})}); } catch {} + try { window.__okvmLatency.pc.close(); } catch {} + window.__okvmLatency = null; + } + + const ice = await api('/webrtc/ice-servers'); + const pc = new RTCPeerConnection({ + iceServers: (ice.ice_servers || []).map(s => ({urls: s.urls, username: s.username, credential: s.credential})) + }); + const media = new MediaStream(); + const video = document.createElement('video'); + video.muted = true; + video.autoplay = true; + video.playsInline = true; + video.style.cssText = 'position:fixed;left:-10000px;top:-10000px;width:640px;height:360px;'; + video.srcObject = media; + document.body.appendChild(video); + pc.ontrack = (event) => { + if (event.track && event.track.kind === 'video') { + media.addTrack(event.track); + video.play().catch(() => {}); + } + }; + pc.addTransceiver('video', {direction: 'recvonly'}); + pc.addTransceiver('audio', {direction: 'recvonly'}); + pc.createDataChannel('hid', {ordered: true, maxRetransmits: 3}); + + let sessionId = null; + const pending = []; + pc.onicecandidate = (event) => { + if (!event.candidate) return; + const item = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + usernameFragment: event.candidate.usernameFragment + }; + if (sessionId) { + api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: item})}).catch(() => {}); + } else { + pending.push(item); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await api('/webrtc/offer', {method: 'POST', body: JSON.stringify({sdp: offer.sdp})}); + if (answer.success === false) { + throw new Error('webrtc_offer_failed: ' + (answer.message || JSON.stringify(answer))); + } + if (typeof answer.sdp !== 'string' || !answer.sdp.trim().startsWith('v=')) { + throw new Error('webrtc_offer_invalid_sdp: ' + JSON.stringify(answer).slice(0, 500)); + } + sessionId = answer.session_id; + await pc.setRemoteDescription({type: 'answer', sdp: answer.sdp}); + for (const c of answer.ice_candidates || []) { + try { await pc.addIceCandidate(c); } catch {} + } + for (const c of pending.splice(0)) { + await api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: c})}).catch(() => {}); + } + + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + if ((pc.connectionState === 'failed') || (pc.connectionState === 'closed')) break; + if ((pc.connectionState === 'connected' || pc.iceConnectionState === 'connected') && video.videoWidth > 0 && video.readyState >= 2) break; + await new Promise(r => setTimeout(r, 50)); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', {willReadFrequently: true}); + window.__okvmLatency = {pc, sessionId, video, canvas, ctx}; + return { + connected: pc.connectionState === 'connected' || pc.iceConnectionState === 'connected' || video.videoWidth > 0, + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + videoWidth: video.videoWidth || 0, + videoHeight: video.videoHeight || 0, + readyState: video.readyState + }; +} +""" + + +WEBRTC_COLOR_DETECT_JS = r""" +async ({targetRgb, timeoutMs, threshold}) => { + const state = window.__okvmLatency; + if (!state || !state.video || !state.ctx) { + throw new Error('WebRTC latency detector is not initialized'); + } + const video = state.video; + const canvas = state.canvas; + const ctx = state.ctx; + const outW = 64; + const outH = 64; + canvas.width = outW; + canvas.height = outH; + const deadline = performance.now() + timeoutMs; + let framesSeen = 0; + let best = null; + + const sample = () => { + const width = video.videoWidth || 0; + const height = video.videoHeight || 0; + if (width <= 0 || height <= 0 || video.readyState < 2) return null; + const sx = Math.max(0, Math.floor(width * 0.35)); + const sy = Math.max(0, Math.floor(height * 0.35)); + const sw = Math.max(1, Math.floor(width * 0.30)); + const sh = Math.max(1, Math.floor(height * 0.30)); + ctx.drawImage(video, sx, sy, sw, sh, 0, 0, outW, outH); + const data = ctx.getImageData(0, 0, outW, outH).data; + let r = 0, g = 0, b = 0; + const pixels = outW * outH; + for (let i = 0; i < data.length; i += 4) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + } + const mean = [r / pixels, g / pixels, b / pixels]; + const errors = [ + Math.abs(mean[0] - targetRgb[0]), + Math.abs(mean[1] - targetRgb[1]), + Math.abs(mean[2] - targetRgb[2]) + ]; + return { + mean_rgb: mean.map(v => Math.round(v * 100) / 100), + mean_abs_error: (errors[0] + errors[1] + errors[2]) / 3, + max_abs_error: Math.max(...errors), + width, + height + }; + }; + + while (performance.now() < deadline) { + const stats = sample(); + if (stats) { + framesSeen += 1; + stats.frames_seen = framesSeen; + if (!best || stats.mean_abs_error < best.mean_abs_error) best = {...stats}; + if (stats.mean_abs_error <= threshold) { + stats.target_detected = true; + stats.wall_ns = Math.round((performance.timeOrigin + performance.now()) * 1000000); + return stats; + } + } + await new Promise(r => setTimeout(r, 16)); + } + const out = best || {mean_rgb: [0, 0, 0], mean_abs_error: 999, max_abs_error: 999, width: 0, height: 0}; + out.target_detected = false; + out.frames_seen = framesSeen; + out.wall_ns = Math.round((performance.timeOrigin + performance.now()) * 1000000); + return out; +} +""" + + +WEBRTC_LATENCY_CLOSE_JS = r""" +async () => { + const state = window.__okvmLatency; + if (!state) return true; + const api = async (path, opts = {}) => { + const response = await fetch('/api' + path, { + credentials: 'include', + headers: {'Content-Type': 'application/json', ...(opts.headers || {})}, + ...opts + }); + return response.json().catch(() => ({})); + }; + try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: state.sessionId})}); } catch {} + try { state.pc.close(); } catch {} + try { state.video.remove(); } catch {} + window.__okvmLatency = null; + return true; +} +""" + + +def local_ip_for(target: str) -> str: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect((target, 80)) + return sock.getsockname()[0] + except Exception: + return "127.0.0.1" + finally: + try: + sock.close() + except Exception: + pass + + +def is_playwright_dependency_error(text: str) -> bool: + return any( + marker in text + for marker in ( + "host system is missing dependencies", + "missing libraries", + "error while loading shared libraries", + "cannot open shared object file", + ) + ) + + +def is_playwright_runtime_error(text: str) -> bool: + return ( + "playwright install" in text + or "playwright is required" in text + or "executable doesn't exist" in text + or "browser distribution" in text + or "is not found at" in text + or "not found on your system" in text + or is_playwright_dependency_error(text) + ) + + +def is_webrtc_codec_unsupported_error(text: str) -> bool: + lower = text.lower() + return any( + marker in lower + for marker in ( + "webrtc_offer_failed", + "webrtc_offer_invalid_sdp", + "failed to parse sessiondescription", + "failed to execute 'setremotedescription'", + "unsupported", + "not supported", + "codec", + ) + ) + + +def is_msd_environment_error(text: str) -> bool: + lower = text.lower() + return any( + marker in lower + for marker in ( + "resources not initialized", + "resource not found", + "boot.img not found", + "core.img not found", + "ventoy.disk.img not found", + "ventoy resources", + ) + ) + + +def default_ventoy_resources_dir() -> Path: + return Path(__file__).resolve().parents[2] / "libs" / "ventoy-img-rs" / "resources" + + +def shell_quote(value: str) -> str: + return value.replace("'", "'\"'\"'") + + +def shell_arg(value: str) -> str: + return "'" + shell_quote(value) + "'" + + +def safe_filename(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") or "evidence" + + +def chromium_launch_args() -> list[str]: + return list(CHROMIUM_WINDOWS_ARGS) + + +def is_windows_controller() -> bool: + return sys.platform.startswith("win") + + +def latency_stats(values: list[float]) -> dict[str, Any]: + if not values: + return {"samples": 0} + return { + "samples": len(values), + "p50_ms": percentile(values, 50), + "p95_ms": percentile(values, 95), + "max_ms": max(values), + "min_ms": min(values), + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="One-KVM automated acceptance test controller") + sub = parser.add_subparsers(dest="command", required=True) + run = sub.add_parser("run", help="run acceptance test") + run.add_argument("--target", required=True, help="One-KVM target IP") + run.add_argument("--http-port", type=int, default=8080) + run.add_argument("--ssh-user", default="root") + run.add_argument("--ssh-port", type=int, default=22) + run.add_argument("--ssh-password", default=None) + run.add_argument("--ssh-password-prompt", action="store_true") + run.add_argument("--data-dir", default="/etc/one-kvm") + run.add_argument("--reset", action="store_true", help="backup and remove One-KVM database before testing") + run.add_argument("--web-user", default="okvmtest") + run.add_argument("--web-password", default="okvmtest1234") + run.add_argument("--run-id", default=None) + run.add_argument("--report-dir", default="reports") + run.add_argument("--health-timeout", type=int, default=60) + run.add_argument("--network-latency-samples", type=int, default=7, help="controller-to-target network latency samples collected during setup") + run.add_argument("--network-latency-timeout", type=float, default=3.0, help="per-sample TCP/HTTP latency timeout in seconds") + run.add_argument("--sample-seconds", type=int, default=30) + run.add_argument("--jpeg-quality", type=int, default=80) + run.add_argument("--mjpeg-motion-fps", type=int, default=60, help="Windows agent dynamic source fps used during MJPEG/HTTP tests") + run.add_argument("--agent-host", default=None, help="Windows agent IP/hostname; omit to skip Windows-side checks") + run.add_argument("--agent-port", type=int, default=8765) + run.add_argument("--agent-timeout", type=int, default=180) + run.add_argument("--msd-size-mb", type=int, default=256) + run.add_argument("--msd-probe-bytes", type=int, default=1024 * 1024) + run.add_argument("--ventoy-resources-dir", default=None, help="local Ventoy resource directory; defaults to repo libs/ventoy-img-rs/resources") + run.add_argument("--no-ventoy-sync", action="store_true", help="do not copy Ventoy resources to the target before MSD testing") + run.add_argument("--no-msd-restart-after-enable", action="store_true", help="do not restart One-KVM after enabling MSD") + run.add_argument("--strict-performance", action="store_true", help="fail video results below the expected FPS threshold; default only requires fps > 0") + run.add_argument("--no-color", action="store_true", help="disable colored terminal output") + run.add_argument("--no-screenshots", action="store_true", help="skip Playwright webpage screenshots") + run.add_argument("--screenshot-wait-ms", type=int, default=6000, help="wait up to this long for video page screenshots to reach a stable state") + run.add_argument("--hid-latency-trials", type=int, default=5) + run.add_argument("--hid-latency-warn-ms", type=float, default=80.0) + run.add_argument("--hid-latency-fail-ms", type=float, default=200.0) + run.add_argument("--no-hdmi-tests", action="store_true", help="skip HDMI identity/color and video visual latency tests") + run.add_argument("--hdmi-settle-ms", type=int, default=600) + run.add_argument("--hdmi-capture-timeout", type=float, default=8.0) + run.add_argument("--hdmi-match-frames", type=int, default=1, help="consecutive matching MJPEG frames required for HDMI color detection") + run.add_argument("--hdmi-color-warn", type=float, default=30.0) + run.add_argument("--hdmi-color-fail", type=float, default=60.0) + run.add_argument("--video-latency-trials", "--hdmi-latency-trials", dest="hdmi_latency_trials", type=int, default=5) + run.add_argument("--video-latency-delay-ms", "--hdmi-latency-delay-ms", dest="hdmi_latency_delay_ms", type=int, default=2000) + run.add_argument("--video-latency-timeout-ms", "--hdmi-latency-timeout-ms", dest="hdmi_latency_timeout_ms", type=int, default=3000) + run.add_argument("--video-latency-warn-ms", "--hdmi-latency-warn-ms", dest="hdmi_latency_warn_ms", type=float, default=3000.0, help=argparse.SUPPRESS) + run.add_argument("--video-latency-fail-ms", "--hdmi-latency-fail-ms", dest="hdmi_latency_fail_ms", type=float, default=3000.0) + run.add_argument("--wol-mac", default="02:00:00:00:00:01") + return parser + + +async def async_main(argv: list[str]) -> int: + args = build_parser().parse_args(argv) + if args.command == "run": + if not is_windows_controller(): + print("okvm_testctl.py run is supported only on native Windows. Run it from Windows PowerShell, not WSL/Linux.", file=sys.stderr) + return 2 + runner = AcceptanceRunner(args) + return await runner.run() + raise AssertionError(args.command) + + +def main() -> None: + try: + raise SystemExit(asyncio.run(async_main(sys.argv[1:]))) + except KeyboardInterrupt: + raise SystemExit(130) + + +if __name__ == "__main__": + main() diff --git a/test/okvm-test/requirements.txt b/test/okvm-test/requirements.txt new file mode 100644 index 00000000..b287302c --- /dev/null +++ b/test/okvm-test/requirements.txt @@ -0,0 +1,5 @@ +httpx>=0.27 +paramiko>=3.4 +websockets>=12 +playwright>=1.45 +Pillow>=10