PDO 循环起来后 native 侧一直跑, 其 --parent-pid 自清理只覆盖"本服务拉起 的实例死掉"; 浏览器模式关窗后本服务还在、窗口模式复用现成 ecat_native (parent 是别人)这两种常见离开方式都够不着 —— 从站卡在 OP、自旋节拍白占 一个核。现由每个 app 页面与页面服务器的 WebSocket 租约兜底: - esp32_server.py: 新增 GET /ws/app-lease(手写 RFC6455 握手 + 帧泵)。 连接计数归零后隔 3s GRACE 调后端 /api/op/stop 停循环退回 INIT; 断开 感知靠 TCP FIN / Close 帧回显而非心跳(后台标签页节流不误停); 代理 透传时 _note_ecat_loop 记账"循环是否本服务跑起来的", 只停自己确认过 的; /api/shutdown 与 serve_forever 退出路径同步补停(定时器随进程死亡)。 - js/common/app-lease.js: 租约客户端, 3~30s 退避重连, file:// 静默跳过。 - 接入 tabs.html(外壳持有, 内部切标签不断连)与 pages/motor.html(单独 打开时页面自持)。 - 测试: scripts/test_app_lease.py(裸 TCP 全路径, 假 _ecat_post) + scripts/test_app_lease_e2e.mjs(真 Edge headless + 假后端 8091, 不碰 真总线)。README 补设计动机与要点。
176 lines
6.9 KiB
JavaScript
176 lines
6.9 KiB
JavaScript
/**
|
|
* test_app_lease_e2e.mjs — app 租约的真浏览器端到端验证。
|
|
*
|
|
* 与 test_app_lease.py 的分工: 那边用裸 TCP 验服务端逻辑; 这边用真 Edge
|
|
* (headless) 验 app-lease.js 在浏览器引擎里真的能握手/断连, 以及
|
|
* "多窗口只关一个不停、全关才停" 的真实时序。
|
|
*
|
|
* 不碰真硬件: 真 ecat_native 可能正占着 8090(它一收 motor/start 就会去动
|
|
* 真总线)。所以假后端放 8091, esp32_server 以"仅改 ECAT_PORTS/autostart
|
|
* 两个常量"的方式跑起来 —— Handler、代理、租约、GRACE 全是真实代码。
|
|
*
|
|
* 用法: node scripts/test_app_lease_e2e.mjs (退出码 0 = 通过)
|
|
*/
|
|
import { spawn, execSync } from "node:child_process";
|
|
import net from "node:net";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
|
const FAKE_PORT = 8091;
|
|
const DBG_PORT = 9334;
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dcu-lease-"));
|
|
const FAKE_LOG = path.join(tmp, "fake.log");
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const procs = [];
|
|
let failed = false;
|
|
|
|
function check(name, cond, detail) {
|
|
if (cond) console.log(" ✓ " + name);
|
|
else { failed = true; console.log(" ✗ " + name + (detail ? " — " + detail : "")); }
|
|
}
|
|
function fakeLog() {
|
|
try { return fs.readFileSync(FAKE_LOG, "utf8"); } catch (_) { return ""; }
|
|
}
|
|
function waitPort(port, timeoutMs = 15000) {
|
|
return new Promise((res, rej) => {
|
|
const t0 = Date.now();
|
|
(function poll() {
|
|
const s = net.connect(port, "127.0.0.1");
|
|
s.on("connect", () => { s.destroy(); res(); });
|
|
s.on("error", () => {
|
|
if (Date.now() - t0 > timeoutMs) rej(new Error("port " + port + " never opened"));
|
|
else setTimeout(poll, 200);
|
|
});
|
|
})();
|
|
});
|
|
}
|
|
function freePort() {
|
|
return new Promise((res) => {
|
|
const s = net.createServer();
|
|
s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); });
|
|
});
|
|
}
|
|
function killTree(pid) {
|
|
try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); } catch (_) {}
|
|
}
|
|
|
|
/* ── 1. 假 EtherCAT 后端(8091): 记录请求路径, 一律回 ok:true ── */
|
|
fs.writeFileSync(path.join(tmp, "fake_ecat.py"), `
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
log = sys.argv[2]
|
|
class H(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.0"
|
|
def do_POST(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
self.rfile.read(n)
|
|
with open(log, "a") as f:
|
|
f.write(self.path + "\\n")
|
|
# 紧凑格式, 与真 native(C snprintf 打印)逐字节同构
|
|
body = b'{"ok":true,"log":["fake"]}'
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
def log_message(self, *a):
|
|
pass
|
|
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
|
|
`);
|
|
procs.push(spawn("python", [path.join(tmp, "fake_ecat.py"), String(FAKE_PORT), FAKE_LOG], { stdio: "ignore" }));
|
|
await waitPort(FAKE_PORT);
|
|
|
|
/* ── 2. 真 esp32_server, 仅把代理端口表钉到 8091、禁掉 autostart ── */
|
|
const srvPort = await freePort();
|
|
fs.writeFileSync(path.join(tmp, "srv.py"), `
|
|
import sys
|
|
sys.path.insert(0, ${JSON.stringify(ROOT)})
|
|
import esp32_server as srv
|
|
srv.ECAT_PORTS = (${FAKE_PORT},)
|
|
srv._ecat_autostart = lambda: (None, [])
|
|
httpd, p = srv._bind_server(${srvPort})
|
|
print("PORT=%d" % p, flush=True)
|
|
httpd.serve_forever()
|
|
`);
|
|
const server = spawn("python", [path.join(tmp, "srv.py")], { cwd: ROOT });
|
|
procs.push(server);
|
|
let serverPort = 0;
|
|
await new Promise((res, rej) => {
|
|
server.stdout.on("data", (d) => {
|
|
const m = /PORT=(\d+)/.exec(String(d));
|
|
if (m) { serverPort = Number(m[1]); res(); }
|
|
});
|
|
server.stderr.on("data", (d) => console.log("[srv] " + String(d).trim()));
|
|
setTimeout(() => rej(new Error("server 未报端口")), 15000);
|
|
});
|
|
const BASE = `http://127.0.0.1:${serverPort}`;
|
|
|
|
/* ── 3. 真 Edge headless + CDP ── */
|
|
const edge = spawn(EDGE, [
|
|
"--headless=new", `--remote-debugging-port=${DBG_PORT}`,
|
|
`--user-data-dir=${path.join(tmp, "profile")}`,
|
|
"--no-first-run", "--disable-gpu", "about:blank",
|
|
], { stdio: "ignore" });
|
|
procs.push(edge);
|
|
await waitPort(DBG_PORT);
|
|
|
|
let page;
|
|
for (let i = 0; i < 40 && !page; i++) {
|
|
await sleep(250);
|
|
try {
|
|
const list = await (await fetch(`http://127.0.0.1:${DBG_PORT}/json/list`)).json();
|
|
page = list.find((t) => t.type === "page");
|
|
} catch (_) {}
|
|
}
|
|
if (!page) throw new Error("拿不到 Edge 调试页面目标");
|
|
const cdp = new WebSocket(page.webSocketDebuggerUrl);
|
|
await new Promise((r, j) => { cdp.onopen = r; cdp.onerror = j; });
|
|
let mid = 0;
|
|
const pend = new Map();
|
|
cdp.onmessage = (e) => {
|
|
const m = JSON.parse(e.data);
|
|
if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); }
|
|
};
|
|
const send = (method, params = {}) => new Promise((res) => {
|
|
const id = ++mid; pend.set(id, res); cdp.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
|
|
/* ── 4. 场景一: 单窗口, 关窗 → 自动停 ── */
|
|
await send("Page.enable");
|
|
await send("Page.navigate", { url: BASE + "/tabs.html" });
|
|
await sleep(2500);
|
|
const r1 = await fetch(BASE + "/ecat/api/motor/start", {
|
|
method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
check("motor/start 经代理成功", r1.ok && (await r1.json()).ok === true);
|
|
check("假后端收到 /api/motor/start", fakeLog().includes("/api/motor/start"));
|
|
await sleep(4500); // > GRACE(3s): 窗口还开着, 绝不能停
|
|
check("窗口在线期间没停循环", !fakeLog().includes("/api/op/stop"));
|
|
await send("Page.navigate", { url: "about:blank" }); // 离开页面 → 租约断开
|
|
await sleep(5000);
|
|
check("关窗(GRACE 后)自动 /api/op/stop", fakeLog().includes("/api/op/stop"));
|
|
|
|
/* ── 5. 场景二: 双窗口, 关一个不停, 全关才停 ── */
|
|
fs.writeFileSync(FAKE_LOG, "");
|
|
const t2 = await send("Target.createTarget", { url: BASE + "/tabs.html" });
|
|
await sleep(2500);
|
|
await fetch(BASE + "/ecat/api/motor/start", {
|
|
method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
await send("Page.navigate", { url: BASE + "/tabs.html" }); // 窗口1 回来续租
|
|
await sleep(1500);
|
|
await send("Target.closeTarget", { targetId: t2.result.targetId });
|
|
await sleep(4500);
|
|
check("还剩一个窗口时不停", !fakeLog().includes("/api/op/stop"));
|
|
await send("Page.navigate", { url: "about:blank" }); // 最后一个也关掉
|
|
await sleep(5000);
|
|
check("全部窗口关闭后自动停", fakeLog().includes("/api/op/stop"));
|
|
|
|
/* ── 6. 收尾 ── */
|
|
try { cdp.close(); } catch (_) {}
|
|
for (const p of procs) killTree(p.pid);
|
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
|
|
console.log(failed ? "E2E FAIL" : "E2E ALL PASS");
|
|
process.exit(failed ? 1 : 0);
|