Update embedded app debugging workflow
This commit is contained in:
1 parent
d50be29246
commit
38807e5f97
13 files changed
+846
-122
No files matched your search
@@ -29,6 +29,7 @@ namespace EmbeddedAppPcDebug
|
||||
private readonly object _logLock = new object();
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly Dictionary<string, DateTime> _webClients = new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HttpListenerResponse> _liveWebClients = new Dictionary<string, HttpListenerResponse>(StringComparer.Ordinal);
|
||||
|
||||
private HttpListener _listener;
|
||||
private FileStream _rawStream;
|
||||
@@ -122,6 +123,8 @@ namespace EmbeddedAppPcDebug
|
||||
_rawPath));
|
||||
|
||||
_listener = new HttpListener();
|
||||
// 必须收到页面连接断开的写异常,不能吞掉后把已关闭的页面永久计为在线。
|
||||
_listener.IgnoreWriteExceptions = false;
|
||||
_listener.Prefixes.Add(_listenPrefix);
|
||||
_listener.Start();
|
||||
|
||||
@@ -269,6 +272,7 @@ namespace EmbeddedAppPcDebug
|
||||
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_stopping) return;
|
||||
_webClients[clientId] = DateTime.UtcNow;
|
||||
_hadWebClient = true;
|
||||
_noClientSinceUtc = DateTime.MinValue;
|
||||
@@ -276,14 +280,76 @@ namespace EmbeddedAppPcDebug
|
||||
}
|
||||
|
||||
private void RemoveWebClient(string clientId)
|
||||
{
|
||||
RemoveWebClient(clientId, null, "网页主动离页");
|
||||
}
|
||||
|
||||
private void RemoveWebClient(string clientId, HttpListenerResponse expectedResponse, string reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId)) return;
|
||||
HttpListenerResponse liveResponse;
|
||||
bool removed;
|
||||
lock (_stateLock)
|
||||
{
|
||||
_webClients.Remove(clientId);
|
||||
_liveWebClients.TryGetValue(clientId, out liveResponse);
|
||||
// EventSource 重连可能先建立新连接,旧连接的 finally 不能删除新连接。
|
||||
if (expectedResponse != null && !object.ReferenceEquals(liveResponse, expectedResponse)) return;
|
||||
_liveWebClients.Remove(clientId);
|
||||
removed = _webClients.Remove(clientId);
|
||||
if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue)
|
||||
_noClientSinceUtc = DateTime.UtcNow;
|
||||
}
|
||||
if (liveResponse != null) { try { liveResponse.Abort(); } catch { } }
|
||||
if (removed && !_stopping) WriteSystemLog(reason + ";客户端 " + clientId);
|
||||
}
|
||||
|
||||
private void SendClientLiveness(HttpListenerContext context)
|
||||
{
|
||||
string clientId = context.Request.QueryString["client"];
|
||||
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128)
|
||||
throw new ArgumentException("页面客户端标识无效。");
|
||||
HttpListenerResponse response = context.Response;
|
||||
HttpListenerResponse previousResponse;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_stopping) throw new IOException("串口网页服务正在结束。");
|
||||
_liveWebClients.TryGetValue(clientId, out previousResponse);
|
||||
_liveWebClients[clientId] = response;
|
||||
_webClients[clientId] = DateTime.UtcNow;
|
||||
_hadWebClient = true;
|
||||
_noClientSinceUtc = DateTime.MinValue;
|
||||
}
|
||||
if (previousResponse != null) { try { previousResponse.Abort(); } catch { } }
|
||||
|
||||
try
|
||||
{
|
||||
response.StatusCode = 200;
|
||||
response.ContentType = "text/event-stream; charset=utf-8";
|
||||
response.Headers["Cache-Control"] = "no-store";
|
||||
response.SendChunked = true;
|
||||
byte[] heartbeat = Encoding.UTF8.GetBytes("retry: 500\n: page connection alive\n\n");
|
||||
// 原生 EventSource 持续连接不依赖页面 setInterval。即使页面定时器被节流,
|
||||
// 仍由服务端每秒探测一次连接;页面关闭/崩溃时,写失败后释放该页面租约。
|
||||
while (!_stopping)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
HttpListenerResponse current;
|
||||
if (!_liveWebClients.TryGetValue(clientId, out current) || !object.ReferenceEquals(current, response)) break;
|
||||
}
|
||||
response.OutputStream.Write(heartbeat, 0, heartbeat.Length);
|
||||
response.OutputStream.Flush();
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
}
|
||||
catch (HttpListenerException) { /* 断开的网页连接是正常生命周期事件,不是串口故障。 */ }
|
||||
catch (IOException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
finally
|
||||
{
|
||||
RemoveWebClient(clientId, response, "网页持续连接已断开(未收到主动离页通知)");
|
||||
try { response.Abort(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void WebClientMonitorLoop()
|
||||
@@ -297,10 +363,14 @@ namespace EmbeddedAppPcDebug
|
||||
List<string> expiredClients = new List<string>();
|
||||
foreach (KeyValuePair<string, DateTime> item in _webClients)
|
||||
{
|
||||
if ((now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
|
||||
if (!_liveWebClients.ContainsKey(item.Key) && (now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
|
||||
expiredClients.Add(item.Key);
|
||||
}
|
||||
foreach (string clientId in expiredClients) _webClients.Remove(clientId);
|
||||
foreach (string clientId in expiredClients)
|
||||
{
|
||||
_webClients.Remove(clientId);
|
||||
WriteSystemLog("网页轮询心跳超时(不能据此断定页面已关闭);客户端 " + clientId);
|
||||
}
|
||||
|
||||
if (_webClients.Count > 0)
|
||||
{
|
||||
@@ -310,13 +380,14 @@ namespace EmbeddedAppPcDebug
|
||||
{
|
||||
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
|
||||
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
|
||||
// 判定和置位必须在同一把锁内,防止刷新页面刚接管就被旧判定停止。
|
||||
if (shouldStop) _stopping = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStop)
|
||||
{
|
||||
WriteSystemLog("最后一个串口网页已关闭,正在停止日志采集并释放串口。");
|
||||
_stopping = true;
|
||||
WriteSystemLog("所有串口网页连接已结束,宽限期内无页面接管;正在停止日志采集并释放串口。");
|
||||
try { if (_listener != null) _listener.Stop(); } catch { }
|
||||
break;
|
||||
}
|
||||
@@ -377,6 +448,13 @@ namespace EmbeddedAppPcDebug
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/api/client/live")
|
||||
{
|
||||
ValidateDisconnectRequest(context);
|
||||
SendClientLiveness(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/api/log")
|
||||
{
|
||||
TouchWebClient(context.Request.QueryString["client"]);
|
||||
@@ -442,7 +520,7 @@ namespace EmbeddedAppPcDebug
|
||||
|
||||
private void ValidateDisconnectRequest(HttpListenerContext context)
|
||||
{
|
||||
// sendBeacon 无法自定义请求头,因此仅离页通知接口允许在查询参数中携带会话令牌。
|
||||
// sendBeacon / EventSource 无法自定义请求头,仅页面生命周期接口允许查询令牌。
|
||||
string token = context.Request.Headers["X-Serial-Token"];
|
||||
if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"];
|
||||
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
|
||||
@@ -503,11 +581,13 @@ namespace EmbeddedAppPcDebug
|
||||
string lastError;
|
||||
DateTime lastReceive;
|
||||
int activeWebClients;
|
||||
int liveWebClients;
|
||||
lock (_stateLock)
|
||||
{
|
||||
lastError = _lastError;
|
||||
lastReceive = _lastReceiveUtc;
|
||||
activeWebClients = _webClients.Count;
|
||||
liveWebClients = _liveWebClients.Count;
|
||||
}
|
||||
|
||||
return "{" +
|
||||
@@ -516,6 +596,7 @@ namespace EmbeddedAppPcDebug
|
||||
"\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," +
|
||||
"\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," +
|
||||
"\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," +
|
||||
"\"liveWebClients\":" + liveWebClients.ToString(CultureInfo.InvariantCulture) + "," +
|
||||
"\"lastError\":\"" + JsonEscape(lastError) + "\"" +
|
||||
"}";
|
||||
}
|
||||
@@ -581,6 +662,14 @@ namespace EmbeddedAppPcDebug
|
||||
public void Stop()
|
||||
{
|
||||
_stopping = true;
|
||||
List<HttpListenerResponse> liveResponses;
|
||||
lock (_stateLock)
|
||||
{
|
||||
liveResponses = new List<HttpListenerResponse>(_liveWebClients.Values);
|
||||
_liveWebClients.Clear();
|
||||
_webClients.Clear();
|
||||
}
|
||||
foreach (HttpListenerResponse response in liveResponses) { try { response.Abort(); } catch { } }
|
||||
try { if (_listener != null) _listener.Stop(); } catch { }
|
||||
try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""V1/V2 身份预检与 EPFP/4001 固件升级工具。"""
|
||||
"""设备 V2 基础信息预检与 EPFP/4001 固件升级工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,8 +21,6 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
INFO_PORT = 8942
|
||||
UPGRADE_PORT = 8943
|
||||
INFO_COMMAND = 30101
|
||||
V1_INFO_PORT = 4941
|
||||
V1_INFO_COMMAND = 0x5A0E2104
|
||||
UPGRADE_COMMAND = 4001
|
||||
MAX_JSON_SIZE = 1024 * 1024
|
||||
MAX_STATUS_SIZE = 1024 * 1024
|
||||
@@ -118,30 +116,6 @@ def query_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[st
|
||||
return response
|
||||
|
||||
|
||||
def query_v1_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[str, Any]:
|
||||
"""通过 V1 工厂 TCP/2104 获取设备身份与固件字段。"""
|
||||
logger.emit("v1_info_request", host=host, port=V1_INFO_PORT,
|
||||
command="0x%08X" % V1_INFO_COMMAND)
|
||||
with socket.create_connection((host, V1_INFO_PORT), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(struct.pack("!II", V1_INFO_COMMAND, 0))
|
||||
header = recv_exact(sock, 8)
|
||||
command, size = struct.unpack("!II", header)
|
||||
if command != V1_INFO_COMMAND or size > MAX_JSON_SIZE:
|
||||
raise UpgradeError("V1 固件信息响应 CMD 或长度异常")
|
||||
payload = recv_exact(sock, size)
|
||||
try:
|
||||
response = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise UpgradeError("V1 固件信息响应不是有效 JSON:%s" % exc) from exc
|
||||
if not isinstance(response, dict) or response.get("code") != 0:
|
||||
raise UpgradeError("V1 固件信息查询失败或响应格式异常")
|
||||
if not response.get("sn") or not response.get("dev_type"):
|
||||
raise UpgradeError("V1 固件信息缺少 SN 或 dev_type,拒绝升级")
|
||||
logger.emit("device_info", protocol="V1", response=response)
|
||||
return response
|
||||
|
||||
|
||||
def response_data(response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""取得响应 data 对象,格式错误时拒绝继续。"""
|
||||
|
||||
@@ -405,11 +379,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"""创建命令行参数解析器。"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="设备身份预检与 EPFP/4001 ROM 升级;默认仅预检。"
|
||||
description="V2 设备 30101 身份预检与 EPFP/4001 ROM 升级;默认仅预检。"
|
||||
)
|
||||
parser.add_argument("--host", required=True, help="目标设备 IPv4/主机名")
|
||||
parser.add_argument("--protocol", choices=("v1", "v2"), default="v2",
|
||||
help="身份查询协议;固件传输均使用 EPFP/4001")
|
||||
parser.add_argument("--expected-sn", required=True, help="必须精确匹配的完整设备 SN")
|
||||
parser.add_argument("--expected-model", help="可选,必须出现在设备型号字段中的型号")
|
||||
parser.add_argument("--rom", required=True, type=Path, help="ROM 文件绝对或相对路径")
|
||||
@@ -450,23 +422,10 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
logger.emit("mode", execute=bool(args.execute), log_file=str(logger.path))
|
||||
rom = validate_rom(args.rom)
|
||||
logger.emit("rom", **rom)
|
||||
if args.protocol == "v1":
|
||||
response = query_v1_device_info(args.host, args.connect_timeout, logger)
|
||||
if response.get("sn") != args.expected_sn:
|
||||
raise UpgradeError("设备 SN 不匹配:期望 %s,实际 %r" % (args.expected_sn, response.get("sn")))
|
||||
if args.expected_model and args.expected_model not in str(response.get("dev_type", "")):
|
||||
raise UpgradeError("设备型号不匹配:期望 %s,实际 %r" % (args.expected_model, response.get("dev_type")))
|
||||
data = {"sn": response.get("sn"), "inner": {"type": response.get("dev_type"),
|
||||
"version": response.get("firmware")}, "outer": {"version": response.get("dev_ver")},
|
||||
"hardware": {"version": response.get("hardware")}}
|
||||
models = [str(response["dev_type"])]
|
||||
else:
|
||||
response = query_device_info(args.host, args.connect_timeout, logger)
|
||||
data, models = validate_identity(response, args.expected_sn, args.expected_model)
|
||||
response = query_device_info(args.host, args.connect_timeout, logger)
|
||||
data, models = validate_identity(response, args.expected_sn, args.expected_model)
|
||||
summary = {
|
||||
"host": args.host,
|
||||
"identity_protocol": args.protocol,
|
||||
"upgrade_protocol_compatibility": "V1 身份查询 + EPFP/4001;仍须依据目标固件资料确认兼容性",
|
||||
"sn": data.get("sn"),
|
||||
"mac": data.get("mac"),
|
||||
"models": models,
|
||||
|
||||
+5
-10
@@ -193,15 +193,14 @@ class SerialWebSession(DeviceSession):
|
||||
def __init__(self, base_url: str, expected_port: str, timeout: float) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.client_id = "module-debug-" + uuid.uuid4().hex
|
||||
page = self._request("/").decode("utf-8", errors="replace")
|
||||
match = re.search(r'data-token="([^"]+)"', page)
|
||||
if not match:
|
||||
raise DebugError("串口网页控制台没有返回会话令牌")
|
||||
self.token = html.unescape(match.group(1))
|
||||
status = json.loads(
|
||||
self._request("/api/status?client=" + urllib.parse.quote(self.client_id))
|
||||
)
|
||||
# 只有用户的网页维护页面租约。自动化读状态/日志不能冒充网页保活,
|
||||
# 否则用户关闭最后页面后,本工具轮询会阻止串口按约定释放。
|
||||
status = json.loads(self._request("/api/status"))
|
||||
if not status.get("isOpen"):
|
||||
raise DebugError("串口网页控制台存在,但串口没有打开")
|
||||
actual_port = str(status.get("port") or "")
|
||||
@@ -234,9 +233,7 @@ class SerialWebSession(DeviceSession):
|
||||
def run(self, command: str, timeout: float = 20.0) -> Tuple[int, str]:
|
||||
marker = "__CODEX_MODULE_RESULT_%s__" % uuid.uuid4().hex
|
||||
wrapped = "(%s); rc=$?; printf '\\n%s=%%s\\n' \"$rc\"" % (command, marker)
|
||||
start_log = self._request(
|
||||
"/api/log?client=" + urllib.parse.quote(self.client_id)
|
||||
).decode("utf-8", errors="replace")
|
||||
start_log = self._request("/api/log").decode("utf-8", errors="replace")
|
||||
self._request(
|
||||
"/api/send?ending=CR",
|
||||
data=wrapped.encode("utf-8"),
|
||||
@@ -247,9 +244,7 @@ class SerialWebSession(DeviceSession):
|
||||
pattern = re.compile(re.escape(marker) + r"=(-?\d+)")
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.2)
|
||||
latest = self._request(
|
||||
"/api/log?client=" + urllib.parse.quote(self.client_id)
|
||||
).decode("utf-8", errors="replace")
|
||||
latest = self._request("/api/log").decode("utf-8", errors="replace")
|
||||
matches = pattern.findall(latest)
|
||||
if matches:
|
||||
offset = min(len(start_log), len(latest))
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// 无浏览器依赖的页面逻辑回归;使用 DOM/网络替身,不操作用户界面或真实串口。
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
async function main() {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
if (!elements.has(id)) {
|
||||
const classes = new Set();
|
||||
elements.set(id, {
|
||||
value: '', textContent: '', checked: true, style: {}, dataset: {},
|
||||
scrollTop: 0, scrollHeight: 1000, clientHeight: 200,
|
||||
selectionStart: 0, selectionEnd: 0, handlers: {},
|
||||
classList: {
|
||||
toggle(name, active) { if (active) classes.add(name); else classes.delete(name); },
|
||||
remove(name) { classes.delete(name); },
|
||||
contains(name) { return classes.has(name); }
|
||||
},
|
||||
addEventListener(name, callback) { this.handlers[name] = callback; },
|
||||
focus() {},
|
||||
setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; }
|
||||
});
|
||||
}
|
||||
return elements.get(id);
|
||||
}
|
||||
const windows = {};
|
||||
const sources = [];
|
||||
const calls = [];
|
||||
const timeouts = new Map();
|
||||
let nextTimer = 1;
|
||||
let log = '[2026-10-21 16:00:00] localhost login:';
|
||||
let hangStatusBody = false;
|
||||
let selectedLog = false;
|
||||
let selectedElsewhere = false;
|
||||
let failLog = false;
|
||||
let onLogBody = null;
|
||||
let deferFrame = false;
|
||||
const frames = [];
|
||||
const bytes = text => new TextEncoder().encode(text).buffer;
|
||||
const sandbox = {
|
||||
console, TextDecoder, TextEncoder, AbortController,
|
||||
document: {
|
||||
body: { dataset: { token: 'test-token', port: 'COM-TEST' } },
|
||||
querySelector: element, querySelectorAll: () => []
|
||||
},
|
||||
window: {
|
||||
crypto: { randomUUID: () => 'test-page' },
|
||||
getSelection: () => ({
|
||||
isCollapsed: !selectedLog && !selectedElsewhere, rangeCount: 1,
|
||||
getRangeAt: () => ({ intersectsNode: node => selectedLog && node === element('#log') })
|
||||
}),
|
||||
addEventListener(name, callback) { windows[name] = callback; }
|
||||
},
|
||||
navigator: { sendBeacon: () => true },
|
||||
EventSource: class {
|
||||
constructor(url) { this.url = url; this.closed = false; sources.push(this); }
|
||||
close() { this.closed = true; }
|
||||
},
|
||||
requestAnimationFrame(callback) { if (deferFrame) frames.push(callback); else callback(); },
|
||||
setInterval() {},
|
||||
setTimeout(callback) { const id = nextTimer++; timeouts.set(id, callback); return id; },
|
||||
clearTimeout(id) { timeouts.delete(id); },
|
||||
async fetch(url, options = {}) {
|
||||
calls.push({ url, options });
|
||||
if (options.method === 'POST') return { ok: true, json: async () => ({ status: 'written' }) };
|
||||
if (url.startsWith('/api/status')) {
|
||||
return {
|
||||
ok: true,
|
||||
arrayBuffer: () => hangStatusBody
|
||||
? new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted'))))
|
||||
: Promise.resolve(bytes(JSON.stringify({ isOpen: true, port: 'COM-TEST', receivedBytes: 10 })))
|
||||
};
|
||||
}
|
||||
if (failLog) throw new Error('test log failure');
|
||||
return { ok: true, arrayBuffer: async () => { if (onLogBody) onLogBody(); return bytes(log); } };
|
||||
}
|
||||
};
|
||||
const context = vm.createContext(sandbox);
|
||||
const html = fs.readFileSync(path.join(__dirname, '../assets/serial-console.html'), 'utf8');
|
||||
const source = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
vm.runInContext(source, context);
|
||||
const run = text => vm.runInContext(text, context);
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
await flush();
|
||||
assert.equal(sources.length, 1);
|
||||
assert.match(sources[0].url, /client=test-page&token=test-token/);
|
||||
assert.equal(element('#log').scrollTop, 1000, '首次显示最新日志');
|
||||
element('#log').scrollTop = 0;
|
||||
log += '\nnew line';
|
||||
await run('refreshLog()');
|
||||
assert.equal(element('#log').scrollTop, 0, '用户向上查看时不抢滚动位置');
|
||||
element('#follow').handlers.change();
|
||||
assert.equal(element('#log').scrollTop, 1000);
|
||||
|
||||
// 即使没有新增日志,也不能无条件替换文本节点。
|
||||
let displayed = element('#log').textContent;
|
||||
let replacements = 0;
|
||||
Object.defineProperty(element('#log'), 'textContent', {
|
||||
get() { return displayed; },
|
||||
set(value) { displayed = value; replacements++; }
|
||||
});
|
||||
await run('refreshLog()');
|
||||
assert.equal(replacements, 0, '相同正文不重建文本节点');
|
||||
const heldText = displayed;
|
||||
selectedLog = true;
|
||||
log += '\narrived while selected';
|
||||
await run('refreshLog()');
|
||||
assert.equal(displayed, heldText, '选中日志时保留文本节点');
|
||||
assert.equal(run('logPolling'), false, '暂停绘制不阻塞下一次轮询');
|
||||
element('#log').scrollTop = 20;
|
||||
element('#follow').handlers.change();
|
||||
assert.equal(element('#log').scrollTop, 20, '有选区时不能自动滚动');
|
||||
failLog = true;
|
||||
await run('refreshLog()');
|
||||
assert.equal(displayed, heldText, '读取失败也不能破坏选区');
|
||||
failLog = false;
|
||||
selectedLog = false;
|
||||
element('#log').handlers.pointerdown({ button: 0 });
|
||||
await run('refreshLog()');
|
||||
assert.equal(displayed, heldText, '拖选尚未形成非空选区时也保护文本');
|
||||
windows.pointerup();
|
||||
await run('refreshLog()');
|
||||
assert.equal(displayed, log, '松开且取消选区后恢复最新日志');
|
||||
selectedElsewhere = true;
|
||||
log += '\nselected outside log';
|
||||
await run('refreshLog()');
|
||||
assert.equal(displayed, log, '选择其他区域不能暂停日志');
|
||||
selectedElsewhere = false;
|
||||
const beforeRace = displayed;
|
||||
log += '\nselection during response';
|
||||
onLogBody = () => { selectedLog = true; };
|
||||
await run('refreshLog()');
|
||||
assert.equal(displayed, beforeRace, '响应等待期间形成的选区也受保护');
|
||||
onLogBody = null;
|
||||
selectedLog = false;
|
||||
element('#log').scrollTop = 1000;
|
||||
deferFrame = true;
|
||||
await run('refreshLog()');
|
||||
assert.ok(frames.length > 0);
|
||||
selectedLog = true;
|
||||
element('#log').scrollTop = 30;
|
||||
for (const callback of frames.splice(0)) callback();
|
||||
assert.equal(element('#log').scrollTop, 30, '布局帧等待期间开始选择也不能抢滚动');
|
||||
selectedLog = false;
|
||||
deferFrame = false;
|
||||
element('#log').handlers.pointerdown({ button: 0 });
|
||||
windows.blur();
|
||||
assert.equal(run('isLogSelectionActive()'), false, '窗口失焦不遗留拖选锁');
|
||||
|
||||
// 正文读取也必须在超时保护范围内,而不只是拿到响应头。
|
||||
hangStatusBody = true;
|
||||
const hung = run('refreshStatus()');
|
||||
await flush();
|
||||
for (const callback of [...timeouts.values()]) callback();
|
||||
await hung;
|
||||
assert.equal(run('statusPolling'), false);
|
||||
hangStatusBody = false;
|
||||
await run('refreshStatus()');
|
||||
assert.equal(run('serialOpen'), true, '超时后能再次请求并恢复连接状态');
|
||||
|
||||
log = 'Password:';
|
||||
await run('refreshLog()');
|
||||
assert.equal(element('#command').classList.contains('masked'), true);
|
||||
element('#command').value = 'fake-test-secret';
|
||||
await run('sendCommand()');
|
||||
assert.equal(run('commandHistory.length'), 0, '密码不进入页面历史');
|
||||
log = 'localhost login:';
|
||||
await run('refreshLog()');
|
||||
element('#command').value = '';
|
||||
const commandCalls = () => calls.filter(call => call.url === '/api/send?ending=CR');
|
||||
const before = commandCalls().length;
|
||||
element('#command').handlers.keydown({ key: 'Enter', isComposing: true });
|
||||
assert.equal(commandCalls().length, before, '输入法候选确认不能发送');
|
||||
element('#command').handlers.keydown({ key: 'Enter', repeat: true, preventDefault() {} });
|
||||
assert.equal(commandCalls().length, before, '长按 Enter 不能重复空发');
|
||||
await run('sendCommand()');
|
||||
assert.equal(commandCalls().at(-1).options.body, '', '空输入按 CR 接口发送');
|
||||
element('#command').value = 'test-command';
|
||||
await run('sendCommand()');
|
||||
assert.equal(commandCalls().at(-1).options.body, 'test-command');
|
||||
assert.equal(element('#command').value, '');
|
||||
element('#command').handlers.keydown({ key: 'ArrowUp', preventDefault() {} });
|
||||
assert.equal(element('#command').value, 'test-command');
|
||||
element('#command').handlers.keydown({ key: 'Escape', preventDefault() {} });
|
||||
assert.equal(element('#command').value, '');
|
||||
|
||||
windows.pagehide();
|
||||
assert.equal(sources[0].closed, true);
|
||||
const count = calls.length;
|
||||
await run('refreshStatus(); refreshLog()');
|
||||
assert.equal(calls.length, count, '离页后不能继续轮询把已关闭页面重新登记');
|
||||
windows.pageshow();
|
||||
await flush();
|
||||
assert.equal(sources.length, 2, '从页面缓存恢复时重建当前页面连接');
|
||||
assert.equal(sources[1].closed, false);
|
||||
windows.pageshow();
|
||||
assert.equal(sources.length, 2, '重复 pageshow 不得创建重复连接');
|
||||
console.log(JSON.stringify({ passed: true, checks: [
|
||||
'liveness lifecycle', 'poll timeout recovery', 'initial/follow scroll',
|
||||
'password history exclusion', 'IME/Enter guard', 'empty CR request',
|
||||
'command request/history', 'pagehide/pageshow', 'unchanged log DOM retention',
|
||||
'selection/drag protection', 'selection during response/frame', 'selection resume'
|
||||
] }));
|
||||
}
|
||||
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""显式使用已确认的串口,回归控制台 HTTP 生命周期;不发送设备命令。
|
||||
|
||||
示例:python test_serial_web_console.py --execute --port COM62 --log-dir <目录>
|
||||
测试独立的本机端口,测试结束释放串口,不创建常驻进程或生产页面保活。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LivePage:
|
||||
"""模拟真正的页面 SSE 连接,持续消费响应;close 模拟页面销毁。"""
|
||||
|
||||
def __init__(self, port, client, token):
|
||||
self.connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
query = urllib.parse.urlencode({"client": client, "token": token})
|
||||
self.connection.request("GET", "/api/client/live?" + query)
|
||||
self.response = self.connection.getresponse()
|
||||
assert self.response.status == 200, self.response.status
|
||||
self.running = True
|
||||
self.frames = 0
|
||||
self.worker = threading.Thread(target=self.consume, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def consume(self):
|
||||
try:
|
||||
while self.running:
|
||||
if not self.response.readline():
|
||||
break
|
||||
self.frames += 1
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.running = False
|
||||
# 主动关闭 socket,不依赖 HTTPResponse.close 等待另一线程的阻塞读取结束。
|
||||
if self.connection.sock:
|
||||
import socket
|
||||
|
||||
try:
|
||||
self.connection.sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
self.connection.close()
|
||||
self.worker.join(2)
|
||||
self.response.close()
|
||||
|
||||
|
||||
class ConsoleTest:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.base = "http://127.0.0.1:%d" % args.web_port
|
||||
self.process = None
|
||||
self.output = None
|
||||
self.pages = []
|
||||
self.results = []
|
||||
|
||||
def get(self, path):
|
||||
with urllib.request.urlopen(self.base + path, timeout=2) as response:
|
||||
return response.read().decode("utf-8")
|
||||
|
||||
def status(self):
|
||||
return json.loads(self.get("/api/status"))
|
||||
|
||||
def record(self, name, **details):
|
||||
record = {"test": name, "passed": True, **details}
|
||||
self.results.append(record)
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
|
||||
def start(self, label):
|
||||
self.log = self.args.log_dir / (label + ".log")
|
||||
assert not self.log.exists(), "不能覆盖既有测试日志"
|
||||
self.output = (self.args.log_dir / (label + ".host.log")).open("wb")
|
||||
script = Path(__file__).with_name("serial_web_console.ps1")
|
||||
self.process = subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "RemoteSigned",
|
||||
"-File", str(script), "-Port", self.args.port, "-LogPath", str(self.log),
|
||||
"-WebPort", str(self.args.web_port)],
|
||||
stdout=self.output, stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
assert self.process.poll() is None, "控制台启动失败,查看 host.log"
|
||||
try:
|
||||
status = self.status()
|
||||
assert status["isOpen"] and status["port"] == self.args.port
|
||||
self.token = re.search(r'data-token="([^"]+)"', self.get("/")).group(1)
|
||||
return
|
||||
except urllib.error.URLError:
|
||||
time.sleep(.1)
|
||||
raise AssertionError("控制台启动超时")
|
||||
|
||||
def page(self, client):
|
||||
page = LivePage(self.args.web_port, client, self.token)
|
||||
self.pages.append(page)
|
||||
return page
|
||||
|
||||
def disconnect(self, client):
|
||||
query = urllib.parse.urlencode({"client": client})
|
||||
request = urllib.request.Request(
|
||||
self.base + "/api/client/disconnect?" + query, data=b"",
|
||||
headers={"X-Serial-Token": self.token}, method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=2) as response:
|
||||
assert response.status == 200
|
||||
|
||||
def wait_exit(self, name, since):
|
||||
self.process.wait(timeout=5)
|
||||
elapsed = time.monotonic() - since
|
||||
assert elapsed < 5 and self.process.returncode == 0, elapsed
|
||||
try:
|
||||
self.status()
|
||||
raise AssertionError("进程结束后接口仍可访问")
|
||||
except urllib.error.URLError:
|
||||
pass
|
||||
text = self.log.read_text(encoding="utf-8")
|
||||
assert "串口网页服务结束" in text
|
||||
assert "[TX:" not in text, "测试不应发送任何设备数据"
|
||||
raw = Path(str(self.log) + ".raw.bin")
|
||||
assert raw.exists()
|
||||
self.output.close()
|
||||
self.output = None
|
||||
self.record(name, exit_seconds=round(elapsed, 3), raw_bytes=raw.stat().st_size)
|
||||
|
||||
def run(self):
|
||||
self.start("lifecycle")
|
||||
time.sleep(5.2)
|
||||
assert self.status()["isOpen"], "从未打开页面时不能自行退出"
|
||||
self.record("no_page_yet")
|
||||
try:
|
||||
self.get("/api/client/live?client=unauthorized")
|
||||
raise AssertionError("缺少令牌仍建立连接")
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 403
|
||||
assert self.status()["activeWebClients"] == 0
|
||||
self.record("liveness_requires_token")
|
||||
|
||||
first = self.page("page-first")
|
||||
second = self.page("page-second")
|
||||
before = self.status()["receivedBytes"]
|
||||
# 远超原有 3+2 秒时限;只保持页面连接,不发送任何轮询心跳。
|
||||
time.sleep(11)
|
||||
status = self.status()
|
||||
assert status["isOpen"] and status["liveWebClients"] == 2
|
||||
assert first.frames > 10 and second.frames > 10
|
||||
assert status["receivedBytes"] > before, "现场 RX 没有增长,不能宣称采集正常"
|
||||
self.record("paused_polling_stays_alive", received_bytes=status["receivedBytes"])
|
||||
|
||||
first.close()
|
||||
time.sleep(5.2)
|
||||
assert self.status()["liveWebClients"] == 1
|
||||
self.record("closing_one_of_two")
|
||||
|
||||
second.close()
|
||||
replacement = self.page("page-refreshed")
|
||||
time.sleep(3.2)
|
||||
assert self.status()["isOpen"] and self.status()["liveWebClients"] == 1
|
||||
self.record("refresh_handoff")
|
||||
|
||||
reconnect = self.page("page-refreshed")
|
||||
replacement.close()
|
||||
time.sleep(3.2)
|
||||
assert self.status()["liveWebClients"] == 1
|
||||
assert reconnect.frames > 3
|
||||
self.record("old_connection_cannot_remove_replacement")
|
||||
start = time.monotonic()
|
||||
self.disconnect("page-refreshed")
|
||||
reconnect.close()
|
||||
self.wait_exit("explicit_last_page_close", start)
|
||||
|
||||
# 再次打开同一 COM,证明上一次已释放句柄;断开最后连接但不发离页通知。
|
||||
self.start("abrupt-disconnect")
|
||||
page = self.page("page-abrupt")
|
||||
time.sleep(.3)
|
||||
start = time.monotonic()
|
||||
page.close()
|
||||
self.wait_exit("abrupt_last_page_close", start)
|
||||
|
||||
self.start("polling-fallback")
|
||||
self.get("/api/status?client=legacy-polling")
|
||||
start = time.monotonic()
|
||||
# 兼容路径总时限约 5 秒(监测线程 250ms 调度允许少量误差)。
|
||||
self.process.wait(timeout=6)
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed < 5.8 and self.process.returncode == 0, elapsed
|
||||
text = self.log.read_text(encoding="utf-8")
|
||||
assert "网页轮询心跳超时" in text and "串口网页服务结束" in text
|
||||
self.record("legacy_polling_fallback", exit_seconds=round(elapsed, 3))
|
||||
|
||||
def cleanup(self):
|
||||
for page in self.pages:
|
||||
try:
|
||||
page.close()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.terminate() # 仅终止本测试创建的控制台,不触碰其他串口工具。
|
||||
self.process.wait(timeout=5)
|
||||
if self.output:
|
||||
self.output.close()
|
||||
(self.args.log_dir / "results.json").write_text(
|
||||
json.dumps(self.results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--port", required=True, help="本次用户已确认的 COM 口")
|
||||
parser.add_argument("--web-port", type=int, default=18766)
|
||||
parser.add_argument("--log-dir", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if not args.execute:
|
||||
parser.error("实际打开串口必须显式指定 --execute")
|
||||
args.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 端口被其他服务占用时停止,不能把别人的响应误当成本次测试服务。
|
||||
import socket
|
||||
|
||||
with socket.socket() as probe:
|
||||
assert probe.connect_ex(("127.0.0.1", args.web_port)) != 0, "测试 HTTP 端口已占用"
|
||||
test = ConsoleTest(args)
|
||||
try:
|
||||
test.run()
|
||||
finally:
|
||||
test.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user