mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 05:41:46 +08:00
test: 增加测试套件
This commit is contained in:
4
test/okvm-test/.gitignore
vendored
Normal file
4
test/okvm-test/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
reports/
|
||||
bin/*.exe
|
||||
187
test/okvm-test/README.md
Normal file
187
test/okvm-test/README.md
Normal file
@@ -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 <Windows测试机IP>` 主动连接它。配套程序默认隐藏测试窗口,只有视频延迟、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 <Windows测试机IP> `
|
||||
--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/<run-id>/report-YYYYMMDD-HHMMSS.md` 和面向用户的迷你摘要 `reports/<run-id>/user-report-YYYYMMDD-HHMMSS.md`,截图、日志和采集帧保存在 `reports/<run-id>/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 <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 <level>` / `--hdmi-color-fail <level>`:调整颜色偏差阈值。
|
||||
- `--video-latency-trials <次数>`:调整切色延迟测试次数,默认 5 次。旧名 `--hdmi-latency-trials` 仍兼容。
|
||||
- `--video-latency-fail-ms <ms>`:调整基本功能阈值,默认 3000ms。旧名 `--hdmi-latency-fail-ms` 仍兼容。
|
||||
|
||||
WebRTC 性能表仍保留连接 RTT/jitter;真实画面级延迟会额外按 MJPEG、H.264、H.265 逐输入输出。
|
||||
|
||||
## 网页截图证据
|
||||
|
||||
默认保留关键过程网页截图到 `reports/<run-id>/evidence/screenshots/`:
|
||||
|
||||
- 登录后控制台首页。
|
||||
- MJPEG 模式网页截图。
|
||||
- H.264 WebRTC 模式网页截图。
|
||||
- H.265 WebRTC 尝试网页截图。
|
||||
- HID/MSD/ATX 测试后的控制台状态。
|
||||
|
||||
视频模式截图会等待页面脱离“等待首帧/连接中”等过渡状态后再保存;H.265 不支持等真实失败页面会直接保留作为证据。等待时间可用 `--screenshot-wait-ms <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 <ms>` 和 `--hid-latency-fail-ms <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 <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/<run-id>/`。
|
||||
- 编译出的 `.exe` 和测试报告默认被 `.gitignore` 忽略。
|
||||
12
test/okvm-test/agent/build-windows.sh
Executable file
12
test/okvm-test/agent/build-windows.sh
Executable file
@@ -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"
|
||||
5
test/okvm-test/agent/go.mod
Normal file
5
test/okvm-test/agent/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module okvm-win-agent
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
test/okvm-test/agent/go.sum
Normal file
2
test/okvm-test/agent/go.sum
Normal file
@@ -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=
|
||||
1202
test/okvm-test/agent/main.go
Normal file
1202
test/okvm-test/agent/main.go
Normal file
File diff suppressed because it is too large
Load Diff
79
test/okvm-test/okvm_media.py
Normal file
79
test/okvm-test/okvm_media.py
Normal file
@@ -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
|
||||
671
test/okvm-test/okvm_report.py
Normal file
671
test/okvm-test/okvm_report.py
Normal file
@@ -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")
|
||||
2319
test/okvm-test/okvm_testctl.py
Executable file
2319
test/okvm-test/okvm_testctl.py
Executable file
File diff suppressed because it is too large
Load Diff
5
test/okvm-test/requirements.txt
Normal file
5
test/okvm-test/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
httpx>=0.27
|
||||
paramiko>=3.4
|
||||
websockets>=12
|
||||
playwright>=1.45
|
||||
Pillow>=10
|
||||
Reference in New Issue
Block a user