#!/usr/bin/env node /** * UI-level diagnosis for motor console (no backend-side health APIs as truth). * Launches Edge against :2625/pages/motor.html, dumps what the page SHOWS. * Usage: node scripts/ui_diag_motor.mjs [port] */ import { spawn } from "node:child_process"; import { writeFileSync, mkdirSync } from "node:fs"; import path from "node:path"; const PORT = Number(process.argv[2] || 2625); const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`; const CDP = "http://127.0.0.1:9333"; const OUT = path.resolve("output/ui_diag"); mkdirSync(OUT, { recursive: true }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function findWs() { for (let i = 0; i < 40; i++) { try { const list = await (await fetch(CDP + "/json/list")).json(); const page = list.find((t) => t.type === "page" && t.url.includes("motor")); if (page && page.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; const any = list.find((t) => t.type === "page"); if (any && any.webSocketDebuggerUrl) return any.webSocketDebuggerUrl; } catch (_) {} await sleep(250); } throw new Error("CDP not available on 9333"); } const edgeCandidates = [ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", ]; function launchEdge() { const exe = edgeCandidates.find((p) => p && require("fs").existsSync(p)) || "msedge.exe"; // prefer spawn without require - use existsSync from fs return spawn( edgeCandidates.find((p) => { try { return require("node:fs").existsSync(p); } catch { return false; } }) || "msedge.exe", [ "--headless=new", "--disable-gpu", `--remote-debugging-port=9333`, "--user-data-dir=" + path.join(OUT, "edge-profile"), "--no-first-run", "--no-default-browser-check", PAGE, ], { stdio: "ignore", detached: true } ); } import { existsSync } from "node:fs"; function launchEdge2() { const exe = edgeCandidates.find((p) => existsSync(p)) || "msedge.exe"; const child = spawn( exe, [ "--headless=new", "--disable-gpu", "--remote-debugging-port=9333", `--user-data-dir=${path.join(OUT, "edge-profile")}`, "--no-first-run", "--no-default-browser-check", PAGE, ], { stdio: "ignore", detached: true } ); child.unref(); return child; } const url = await (async () => { try { await fetch(CDP + "/json/list"); } catch { console.log("launching Edge headless →", PAGE); launchEdge2(); await sleep(2000); } return findWs(); })(); console.log("CDP", url); const sock = new WebSocket(url); await new Promise((r, j) => { sock.onopen = r; sock.onerror = j; }); let id = 0; const pending = new Map(); const consoleMsgs = []; sock.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } if (m.method === "Runtime.consoleAPICalled") { const args = (m.params.args || []) .map((a) => a.value ?? a.description ?? a.unserializableValue ?? "") .join(" "); consoleMsgs.push(`[${m.params.type}] ${args}`); } if (m.method === "Runtime.exceptionThrown") { const d = m.params.exceptionDetails || {}; consoleMsgs.push( `[exception] ${d.text || ""} ${d.exception && d.exception.description ? d.exception.description : ""}` ); } if (m.method === "Log.entryAdded") { const e = m.params.entry || {}; consoleMsgs.push(`[log:${e.level}] ${e.text}`); } }; function send(method, params = {}) { const myId = ++id; return new Promise((res) => { pending.set(myId, res); sock.send(JSON.stringify({ id: myId, method, params })); }); } async function evaluate(expr) { const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true, }); if (r.result && r.result.exceptionDetails) { return { __err: JSON.stringify(r.result.exceptionDetails).slice(0, 500), }; } return r.result && r.result.result && r.result.result.value; } await send("Runtime.enable"); await send("Log.enable"); await send("Page.enable"); await send("Page.navigate", { url: PAGE }); await sleep(4000); const report = await evaluate(`(() => { const $ = (s) => document.querySelector(s); const txt = (s) => { const e=$(s); return e ? (e.textContent||'').trim() : null; }; const cls = (s) => { const e=$(s); return e ? e.className : null; }; const nodes = [...document.querySelectorAll('#nodeList .node-item, #nodeList .node, #nodeList [data-node], #nodeList button, #nodeList .node-row')].map(e => ({ tag: e.tagName, cls: e.className, text: (e.textContent||'').trim().slice(0,80), html: (e.innerHTML||'').slice(0,120) })); const statusPanel = $('#statusPanel, .status-panel, #statusBody'); const sb = {}; for (const id of ['sbLastSend','sbRx','sbTx','sbErr','devStatus','ipLabel','connLed','nodeBadge','netBannerText','refreshRate']) { const e = document.getElementById(id); sb[id] = e ? {text:(e.textContent||'').trim().slice(0,80), cls:e.className, value:e.value??null, title:e.title||null} : null; } let dbg = null; try { const s = window.motorDbg && window.motorDbg.state; if (s) { dbg = { connected: s.connected, connecting: s.connecting, probing: s.probing, tpId: s.tpId, ip: s.ip, probeErr: s.probeErr, probeErrFull: (s.probeErrFull||'').slice(0,200), failCount: s.failCount, refreshIntervalMs: s.refreshIntervalMs, motorsLen: (s.motors||[]).length, motorsSample: (s.motors||[]).slice(0,3).map(m=>({ch:m.ch,node:m.node,online:m.online,dlc:m.dlc,dataLen:(m.data||'').length})), chOnline: s.chOnline, chEnabled: s.chEnabled, selectedNode: s.selectedNode, decodedSize: s.decoded && s.decoded.size, recvTsLen: (s.recvTs||[]).length, tickStuck: s.tickStuck, txCount: s.txCount, transportHas: !!(s.tp), transportId: s.tp && s.tp.id, lastConnToastAt: s.lastConnToastAt, }; } } catch(e) { dbg = {err:String(e)}; } // overlay / empty states visible to user const overlays = [...document.querySelectorAll('.offline-overlay,.empty-state,.status-offline,#statusEmpty,.panel-empty')] .map(e => ({cls:e.className, text:(e.textContent||'').trim().slice(0,100), hidden:e.hidden, display:getComputedStyle(e).display})); // any toast visible const toasts = [...document.querySelectorAll('#toastContainer .toast, .toast')] .map(e => ({cls:e.className, text:(e.textContent||'').trim().slice(0,80)})); return { title: document.title, url: location.href, readyState: document.readyState, bodyClass: document.body && document.body.className, htmlData: document.documentElement.dataset, toolbar: { brand: txt('.toolbar-brand span'), sub: txt('.toolbar-brand .sub'), devStatus: txt('#devStatus'), connLedCls: cls('#connLed'), ipLabel: txt('#ipLabel'), transportSelect: document.querySelector('#transportSelect, #linkSelect, select[id*=transport], select[id*=link]')?.value, transportOptions: [...document.querySelectorAll('select option')].map(o=>o.value+':'+o.textContent.trim()).slice(0,12), }, sb, nodeListCount: nodes.length, nodes, statusPanelText: statusPanel ? (statusPanel.textContent||'').trim().slice(0,400) : null, overlays, toasts, dbg, scripts: [...document.scripts].map(s=>s.src).filter(Boolean), }; })()`); const consoleTail = consoleMsgs.slice(-40); const dump = { when: new Date().toISOString(), PAGE, report, consoleTail }; const outFile = path.join(OUT, "ui_diag.json"); writeFileSync(outFile, JSON.stringify(dump, null, 2), "utf8"); console.log("WROTE", outFile); console.log(JSON.stringify({ title: report && report.title, toolbar: report && report.toolbar, sb: report && report.sb, nodeListCount: report && report.nodeListCount, nodes: report && report.nodes, overlays: report && report.overlays, toasts: report && report.toasts, dbg: report && report.dbg, scripts: report && report.scripts, consoleTail }, null, 2)); if (report && report.__err) console.log("EVAL_ERR", report.__err); sock.close(); process.exit(0);