test:@test 闭环迁移 PowerShell→Python(run_boot_test/verify_log 纯标准库,文档同步)
This commit is contained in:
@@ -1,174 +0,0 @@
|
||||
<#
|
||||
run_boot_test.ps1 - Automated boot-log capture + verify in one call.
|
||||
|
||||
Uses the repo's Python serial assistant (tools/serial_tool/serial_tool.py,
|
||||
resident daemon on 127.0.0.1:18765, auto-started on first call):
|
||||
1. open COM port + clear log (fresh generation for this run)
|
||||
2. flash via -FlashCmd (download.bat: download + Reset & Run)
|
||||
3. wait-idle: until RX silence >= SettleMs, or MaxWaitMs timeout
|
||||
4. extract [RX] payload lines from the log file (prefix/timestamp stripped)
|
||||
5. verify_log.ps1 marker check
|
||||
|
||||
The capture is armed (port open, log cleared) BEFORE flashing, and the
|
||||
resident daemon buffers everything in tools/serial_tool/state/serial.log -
|
||||
no capture-window timing ritual, no first-byte wait, no EOF retries.
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File run_boot_test.ps1 `
|
||||
[-Port COM6] [-BaudRate 115200] `
|
||||
[-SettleMs 8000] [-MaxWaitMs 30000] `
|
||||
[-FlashCmd "D:\...\download.bat"] `
|
||||
[-Required "App init OK;Network Init;net init: OK"] `
|
||||
[-Forbidden "FAIL;ERROR;HardFault"] `
|
||||
[-OutDir C:\temp] [-ShowTailLines 15]
|
||||
|
||||
-Port default: .opencode/serial_config.json "portName" entry.
|
||||
-SettleMs: RX silence that ends the capture (default 8000 - boot has a
|
||||
natural 3~4s silent gap between tasks; 2000 would truncate the log).
|
||||
|
||||
Exit codes:
|
||||
0 = PASS (captured + all required markers + no forbidden)
|
||||
1 = FAIL (marker miss / forbidden hit)
|
||||
2 = capture or flash error
|
||||
3 = usage / preflight error
|
||||
|
||||
NOTE: ASCII-only on purpose - project source files contain Chinese and must
|
||||
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Port = "",
|
||||
[int]$BaudRate = 115200,
|
||||
[int]$SettleMs = 8000,
|
||||
[int]$MaxWaitMs = 30000,
|
||||
[string]$FlashCmd = "",
|
||||
[string]$Required = "App init OK;Network Init;net init: OK",
|
||||
[string]$Forbidden = "FAIL;ERROR;HardFault",
|
||||
[string]$OutDir = "$env:TEMP\opencode",
|
||||
[int]$ShowTailLines = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
$py = Join-Path $repoRoot ".venv\Scripts\python.exe"
|
||||
$tool = Join-Path $repoRoot "tools\serial_tool\serial_tool.py"
|
||||
$verifyScript = Join-Path $scriptDir "verify_log.ps1"
|
||||
$cfgFile = Join-Path $repoRoot ".opencode\serial_config.json"
|
||||
|
||||
# -Port default: the single source of truth is .opencode/serial_config.json
|
||||
if (-not $Port) {
|
||||
if (Test-Path -LiteralPath $cfgFile) {
|
||||
$Port = (Get-Content -LiteralPath $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json).portName
|
||||
}
|
||||
}
|
||||
if (-not $Port) {
|
||||
Write-Output "RUN_TEST_ERROR: no -Port given and no portName in $cfgFile"
|
||||
exit 3
|
||||
}
|
||||
|
||||
# Default flash command: resolve relative to the repo (no stale absolute paths)
|
||||
if (-not $FlashCmd) {
|
||||
$FlashCmd = Join-Path $repoRoot "MDK-ARM\download.bat"
|
||||
}
|
||||
|
||||
foreach ($p in @($py, $tool, $verifyScript)) {
|
||||
if (-not (Test-Path -LiteralPath $p)) {
|
||||
Write-Output "RUN_TEST_ERROR: missing $p"
|
||||
exit 3
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $FlashCmd)) {
|
||||
Write-Output "RUN_TEST_ERROR: flash command not found: $FlashCmd"
|
||||
exit 3
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutDir)) {
|
||||
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$bootTxt = Join-Path $OutDir "boot_log_$stamp.txt"
|
||||
|
||||
function Invoke-Tool {
|
||||
param(
|
||||
[Parameter(Position = 0)][string]$Command,
|
||||
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest
|
||||
)
|
||||
$script:toolOut = (& $py $tool $Command @Rest 2>$null) -join "`n"
|
||||
$script:toolRc = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Output "=== Boot test $stamp ==="
|
||||
Write-Output "Port: $Port @ ${BaudRate} settle=${SettleMs}ms max=${MaxWaitMs}ms"
|
||||
|
||||
# 1. open port (auto-starts the serial daemon if it is not running)
|
||||
Invoke-Tool "open" "--port" $Port "--baud" $BaudRate "--timeout" 15
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: open failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 2. clear log (fresh generation; only data after this belongs to the run)
|
||||
Invoke-Tool "clear" "--timeout" 15
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: clear failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 3. flash (download + Reset & Run). A failed flash does not abort: the
|
||||
# board may still have reset; capture + marker check will judge the result.
|
||||
Write-Output "FLASH_START: $FlashCmd"
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $FlashCmd
|
||||
$flashRc = $LASTEXITCODE
|
||||
$sw.Stop()
|
||||
Write-Output ("FLASH_RC: {0} elapsed_ms={1}" -f $flashRc, $sw.ElapsedMilliseconds)
|
||||
if ($flashRc -ne 0) {
|
||||
Write-Output "RUN_TEST_WARN: flash rc=$flashRc (continuing: board may still have reset)"
|
||||
}
|
||||
|
||||
# 4. wait for RX silence (settle) or the overall timeout
|
||||
$waitTimeoutS = [int]($MaxWaitMs / 1000) + 20
|
||||
Invoke-Tool "wait-idle" "--settle-ms" $SettleMs "--max-ms" $MaxWaitMs "--timeout" $waitTimeoutS
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: wait-idle failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
$wait = $script:toolOut | ConvertFrom-Json
|
||||
Write-Output ("WAIT: reason={0} rx_bytes={1} waited_ms={2}" -f $wait.reason, $wait.rx_bytes, $wait.waited_ms)
|
||||
if ($wait.reason -eq "no_data") {
|
||||
Write-Output "RUN_TEST_ERROR: no RX data at all (check port / board power / flash)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 5. extract [RX] payload lines (strip the "[RX] <iso-timestamp> " prefix)
|
||||
Invoke-Tool "log" "--tail" 0 "--timeout" 15
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: log read failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
$logJson = $script:toolOut | ConvertFrom-Json
|
||||
$rx = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($l in $logJson.lines) {
|
||||
if ($l -is [string] -and $l.StartsWith("[RX] ")) {
|
||||
$rest = $l.Substring(5)
|
||||
$sp = $rest.IndexOf(" ")
|
||||
if ($sp -ge 0) { $rx.Add($rest.Substring($sp + 1)) } else { $rx.Add($rest) }
|
||||
}
|
||||
}
|
||||
if ($rx.Count -eq 0) {
|
||||
Write-Output "RUN_TEST_ERROR: no [RX] lines in log (device silent?)"
|
||||
exit 2
|
||||
}
|
||||
[System.IO.File]::WriteAllLines($bootTxt, $rx, (New-Object System.Text.UTF8Encoding($false)))
|
||||
Write-Output "Boot log: $bootTxt (lines=$($rx.Count) gen=$($logJson.gen))"
|
||||
|
||||
# 6. marker verification
|
||||
& $verifyScript -LogPath $bootTxt -Required $Required -Forbidden $Forbidden `
|
||||
-ShowTailLines $ShowTailLines
|
||||
$verifyRc = $LASTEXITCODE
|
||||
Write-Output "RUN_TEST_RC=$verifyRc"
|
||||
exit $verifyRc
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
|
||||
|
||||
调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON):
|
||||
1. open 串口 + clear 日志(本次运行独立代际)
|
||||
2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run)
|
||||
3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时
|
||||
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
5. verify_log.py 标记校验
|
||||
|
||||
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
||||
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
|
||||
|
||||
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
|
||||
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
|
||||
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
|
||||
导致 "Unterminated string" 崩溃的问题
|
||||
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
|
||||
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/run_boot_test.py
|
||||
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
|
||||
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
|
||||
[--required "App init OK;Network Init;net init: OK"]
|
||||
[--forbidden "FAIL;ERROR;HardFault"]
|
||||
[--out-dir <目录>] [--show-tail-lines 15]
|
||||
|
||||
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||||
1 = FAIL(标记缺失/命中)
|
||||
2 = 采集或烧录错误
|
||||
3 = 用法/预检错误
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
|
||||
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
|
||||
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault"
|
||||
|
||||
|
||||
def _cli(cmd_args, timeout):
|
||||
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
|
||||
|
||||
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
|
||||
任何 Windows 控制台代码页都不参与数据路径。
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 2, "client timeout ({}s)".format(timeout), None
|
||||
text = proc.stdout.decode("utf-8", errors="replace")
|
||||
try:
|
||||
return proc.returncode, text, json.loads(text)
|
||||
except ValueError:
|
||||
return proc.returncode, text, None
|
||||
|
||||
|
||||
def _tool_cmd(py, cmd, *rest, timeout=15):
|
||||
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
|
||||
if rc != 0 or data is None:
|
||||
return None, out
|
||||
return data, out
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
|
||||
ap.add_argument("--port", default="",
|
||||
help="serial port (default: portName from .opencode/serial_config.json)")
|
||||
ap.add_argument("--baud-rate", type=int, default=115200)
|
||||
ap.add_argument("--settle-ms", type=int, default=8000,
|
||||
help="RX silence that ends the capture")
|
||||
ap.add_argument("--max-wait-ms", type=int, default=30000,
|
||||
help="overall wait-idle timeout")
|
||||
ap.add_argument("--flash-cmd", default="",
|
||||
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
|
||||
ap.add_argument("--flash-timeout", type=int, default=180,
|
||||
help="flash subprocess timeout in seconds")
|
||||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||||
help="required markers, ';' separated")
|
||||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||||
help="forbidden patterns, ';' separated")
|
||||
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
|
||||
ap.add_argument("--show-tail-lines", type=int, default=15)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
|
||||
py = sys.executable
|
||||
|
||||
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM)
|
||||
port = args.port
|
||||
if not port and CFG_FILE.is_file():
|
||||
try:
|
||||
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
|
||||
port = str(cfg.get("portName") or "")
|
||||
except (OSError, ValueError) as e:
|
||||
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
|
||||
return 3
|
||||
if not port:
|
||||
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
|
||||
return 3
|
||||
|
||||
flash_cmd = Path(args.flash_cmd) if args.flash_cmd else DEFAULT_FLASH
|
||||
|
||||
# 预检(对应旧版 missing-file 检查)
|
||||
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
|
||||
if not p.is_file():
|
||||
print("RUN_TEST_ERROR: missing {}".format(p))
|
||||
return 3
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
|
||||
|
||||
def tool(cmd, *rest, timeout=15):
|
||||
return _tool_cmd(py, cmd, *rest, timeout=timeout)
|
||||
|
||||
print("=== Boot test {} ===".format(stamp))
|
||||
print("Port: {} @ {} settle={}ms max={}ms".format(
|
||||
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
||||
|
||||
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
||||
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: open failed: {}".format(out))
|
||||
return 2
|
||||
|
||||
# 2. clear(新代际;此后数据才属于本次运行)
|
||||
data, out = tool("clear", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: clear failed: {}".format(out))
|
||||
return 2
|
||||
|
||||
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
|
||||
# 由采集 + 标记判定结果(与旧版一致)
|
||||
print("FLASH_START: {}".format(flash_cmd))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(["cmd", "/c", str(flash_cmd)],
|
||||
capture_output=True, timeout=args.flash_timeout)
|
||||
flash_rc = proc.returncode
|
||||
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
flash_rc = -1
|
||||
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
|
||||
print(line)
|
||||
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
|
||||
if flash_rc != 0:
|
||||
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
|
||||
|
||||
# 4. wait-idle(RX 静默收尾或整体超时)
|
||||
wait_timeout_s = args.max_wait_ms // 1000 + 20
|
||||
data, out = tool("wait-idle",
|
||||
"--settle-ms", str(args.settle_ms),
|
||||
"--max-ms", str(args.max_wait_ms),
|
||||
"--timeout", str(wait_timeout_s),
|
||||
timeout=wait_timeout_s + 15)
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
|
||||
return 2
|
||||
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
|
||||
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
|
||||
if data.get("reason") == "no_data":
|
||||
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
|
||||
return 2
|
||||
|
||||
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
data, out = tool("log", "--tail", "0", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: log read failed: {}".format(out))
|
||||
return 2
|
||||
rx = []
|
||||
for l in data.get("lines", []):
|
||||
if isinstance(l, str) and l.startswith("[RX] "):
|
||||
rest = l[5:]
|
||||
sp = rest.find(" ")
|
||||
rx.append(rest[sp + 1:] if sp >= 0 else rest)
|
||||
if not rx:
|
||||
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
|
||||
return 2
|
||||
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
|
||||
print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen")))
|
||||
|
||||
# 6. 标记校验
|
||||
from verify_log import verify
|
||||
rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines)
|
||||
print("RUN_TEST_RC={}".format(rc))
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,102 +0,0 @@
|
||||
<#
|
||||
verify_log.ps1 - Check a captured MCU debug log against boot/test markers.
|
||||
|
||||
Used by the @test loop: after flashing, the fresh boot log is extracted from
|
||||
the serial assistant log (tools/serial_tool), then this script greps it and
|
||||
prints a structured verdict.
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File verify_log.ps1 `
|
||||
-LogPath <file> `
|
||||
[-Required 'marker1;marker2'] `
|
||||
[-Forbidden 'FAIL;ERROR;HardFault'] `
|
||||
[-ShowTailLines 40]
|
||||
|
||||
Exit code:
|
||||
0 = PASS (every required marker found, no forbidden pattern matched)
|
||||
1 = FAIL (missing required marker or forbidden pattern present)
|
||||
2 = ERROR (log file not found / unreadable)
|
||||
|
||||
Default required markers match the STM32F4-Base boot sequence
|
||||
(see App/app_main.c + App/task/net_task.c). Override per test case, e.g.:
|
||||
-Required 'App init OK;net init: OK;PHY: 100M FULL'
|
||||
-Forbidden 'FAIL;ERROR;HardFault;assert'
|
||||
|
||||
NOTE: ASCII-only on purpose - project source files contain Chinese and must
|
||||
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogPath,
|
||||
|
||||
[string]$Required = 'App init OK;Network Init;net init: OK',
|
||||
[string]$Forbidden = 'FAIL;ERROR;HardFault;assert',
|
||||
[int]$ShowTailLines = 40
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $LogPath)) {
|
||||
Write-Output "VERIFY_ERROR: log file not found: $LogPath"
|
||||
exit 2
|
||||
}
|
||||
|
||||
try {
|
||||
$content = Get-Content -LiteralPath $LogPath -Encoding UTF8 -ErrorAction Stop
|
||||
} catch {
|
||||
# Fall back to default encoding if UTF-8 read fails
|
||||
try {
|
||||
$content = Get-Content -LiteralPath $LogPath -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Output "VERIFY_ERROR: cannot read log file: $LogPath"
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
$all = $content -join "`n"
|
||||
|
||||
$requiredList = @($Required -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
$forbiddenList = @($Forbidden -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
|
||||
Write-Output "=== Log verdict: $LogPath ==="
|
||||
Write-Output ("Lines: {0}, bytes: {1}" -f $content.Count, (Get-Item -LiteralPath $LogPath).Length)
|
||||
|
||||
$missing = @()
|
||||
foreach ($m in $requiredList) {
|
||||
if ($all.Contains($m)) {
|
||||
Write-Output ("[OK] required marker found: {0}" -f $m)
|
||||
} else {
|
||||
Write-Output ("[MISS] required marker NOT found: {0}" -f $m)
|
||||
$missing += $m
|
||||
}
|
||||
}
|
||||
|
||||
$hits = @()
|
||||
foreach ($f in $forbiddenList) {
|
||||
$matches = @($content | Where-Object { $_ -like "*$f*" })
|
||||
if ($matches.Count -gt 0) {
|
||||
Write-Output ("[HIT] forbidden pattern: {0} ({1} line(s))" -f $f, $matches.Count)
|
||||
$hits += $f
|
||||
} else {
|
||||
Write-Output ("[OK] forbidden pattern absent: {0}" -f $f)
|
||||
}
|
||||
}
|
||||
|
||||
if ($ShowTailLines -gt 0 -and $content.Count -gt 0) {
|
||||
Write-Output ""
|
||||
Write-Output "=== Last $ShowTailLines line(s) of log ==="
|
||||
$start = [Math]::Max(0, $content.Count - $ShowTailLines)
|
||||
$content[$start..($content.Count - 1)] | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0 -or $hits.Count -gt 0) {
|
||||
Write-Output ""
|
||||
Write-Output "VERDICT: FAIL"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output ""
|
||||
Write-Output "VERDICT: PASS"
|
||||
exit 0
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
|
||||
|
||||
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
|
||||
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/verify_log.py --log-path <file>
|
||||
[--required 'marker1;marker2']
|
||||
[--forbidden 'FAIL;ERROR;HardFault;assert']
|
||||
[--show-tail-lines 40]
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(所有 Required 标记找到,Forbidden 零命中)
|
||||
1 = FAIL(缺 Required 或命中 Forbidden)
|
||||
2 = ERROR(日志文件不存在/不可读)
|
||||
|
||||
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app_main.c + App/task/net_task.c),
|
||||
按测试用例覆盖,例如:
|
||||
--required 'App init OK;net init: OK;PHY: 100M FULL'
|
||||
--forbidden 'FAIL;ERROR;HardFault;assert'
|
||||
|
||||
语义约定(与旧 PowerShell 版对齐):
|
||||
- Required:大小写敏感子串匹配(全文)
|
||||
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
|
||||
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
|
||||
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
|
||||
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
|
||||
都只影响终端显示,不会导致本脚本崩溃
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import locale
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert"
|
||||
|
||||
|
||||
def _split_list(spec):
|
||||
"""';' 分隔列表 → 去空白、去空项。"""
|
||||
return [p.strip() for p in spec.split(";") if p.strip()]
|
||||
|
||||
|
||||
def _read_lines(path):
|
||||
raw = Path(path).read_bytes()
|
||||
for enc in ("utf-8", locale.getpreferredencoding(False)):
|
||||
try:
|
||||
return raw.decode(enc).splitlines()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace").splitlines()
|
||||
|
||||
|
||||
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
show_tail=40, out=print):
|
||||
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。"""
|
||||
p = Path(log_path)
|
||||
if not p.is_file():
|
||||
out("VERIFY_ERROR: log file not found: {}".format(log_path))
|
||||
return 2
|
||||
try:
|
||||
lines = _read_lines(log_path)
|
||||
except OSError:
|
||||
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
||||
return 2
|
||||
|
||||
full = "\n".join(lines)
|
||||
req_list = _split_list(required)
|
||||
forb_list = _split_list(forbidden)
|
||||
|
||||
out("=== Log verdict: {} ===".format(log_path))
|
||||
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
|
||||
|
||||
missing = []
|
||||
for m in req_list:
|
||||
if m in full:
|
||||
out("[OK] required marker found: {}".format(m))
|
||||
else:
|
||||
out("[MISS] required marker NOT found: {}".format(m))
|
||||
missing.append(m)
|
||||
|
||||
hits = []
|
||||
for f in forb_list:
|
||||
fl = f.lower()
|
||||
n = sum(1 for ln in lines if fl in ln.lower())
|
||||
if n > 0:
|
||||
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, n))
|
||||
hits.append(f)
|
||||
else:
|
||||
out("[OK] forbidden pattern absent: {}".format(f))
|
||||
|
||||
if show_tail > 0 and lines:
|
||||
out("")
|
||||
out("=== Last {} line(s) of log ===".format(show_tail))
|
||||
for ln in lines[-show_tail:]:
|
||||
out(ln)
|
||||
|
||||
out("")
|
||||
if missing or hits:
|
||||
out("VERDICT: FAIL")
|
||||
return 1
|
||||
out("VERDICT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
|
||||
ap.add_argument("--log-path", required=True, help="boot log file to check")
|
||||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||||
help="required markers, ';' separated (case-sensitive)")
|
||||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||||
help="forbidden patterns, ';' separated (case-insensitive)")
|
||||
ap.add_argument("--show-tail-lines", type=int, default=40,
|
||||
help="show last N log lines (0 = off)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
return verify(args.log_path, args.required, args.forbidden, args.show_tail_lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user