/** * 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);