// Plumbing for driving a throwaway Theseus over CDP. // // This lives in the repo on purpose. It used to sit in scratchpad/, which is // not tracked, and on 2026-10-22 a tidy-up deleted it along with the whole // end-to-end suite — the only level of testing that catches breakage which // exists solely in the browser. Rebuilding it cost an afternoon. Anything // load-bearing belongs next to the code it tests. // // Two Windows lessons are baked in: // // * Spawn electron.exe directly, never the .bin shim with shell:true. The // shim is a .cmd, so shell:true puts cmd.exe in between; killing the // child then kills cmd and orphans a dozen Electron processes that keep // the scratch profile locked, and the next run dies with EPERM. // // * Kill by command line, not by pid tree. The developer very often has // their own Theseus running from this same checkout, and a blanket // Stop-Process -Name electron takes it down too. Every process we start // carries the scratch profile path in its command line, which is a // precise and safe filter. import http from "node:http"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawn, execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; export const HERE = path.dirname(fileURLToPath(import.meta.url)); export const ROOT = path.resolve(HERE, "../../.."); // TheseusNavigator/ export const PORT = 9333; // The throwaway profile goes in the OS temp dir, NOT under this folder. // addon-build/docx-editor/package.json declares "type": "module", and Node // decides a file's module kind from the nearest package.json above it — so an // add-on seeded into a profile under here is parsed as ESM and dies on its // first require() with "require is not defined in ES module scope". The // add-on is fine; the profile's location was not. export const MARKER = "theseus-docx-e2e"; export const USER_DATA = path.join(os.tmpdir(), MARKER); export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function electronExe() { const p = path.join(ROOT, "node_modules", "electron", "dist", process.platform === "win32" ? "electron.exe" : "electron"); if (!fs.existsSync(p)) { throw new Error(`electron not installed at ${p}\nRun: cd ${ROOT} && npm install`); } return p; } /** Kill every Electron process started against our scratch profile. */ export function killStrays() { if (process.platform !== "win32") return; const needle = USER_DATA.replace(/\\/g, "\\\\"); const ps = `1..8 | ForEach-Object { Get-CimInstance Win32_Process -Filter "Name='electron.exe'" | Where-Object { $_.CommandLine -like '*${needle}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds 400 }`; try { execFileSync("powershell", ["-NoProfile", "-Command", ps], { stdio: "ignore" }); } catch {} } export function freshProfile() { killStrays(); for (let i = 0; i < 12; i++) { try { fs.rmSync(USER_DATA, { recursive: true, force: true }); break; } catch { try { execFileSync("powershell", ["-NoProfile", "-Command", "Start-Sleep -Milliseconds 500"], { stdio: "ignore" }); } catch {} } } fs.mkdirSync(USER_DATA, { recursive: true }); } export function boot({ onLine, keepProfile } = {}) { if (!keepProfile) freshProfile(); const child = spawn(electronExe(), [ROOT, `--remote-debugging-port=${PORT}`], { cwd: ROOT, env: { ...process.env, THESEUS_USER_DATA: USER_DATA, // A throwaway instance running older code than the update channel // advertises will otherwise offer a real update, and one click // reinstalls over the developer's actual Theseus. THESEUS_NO_UPDATE_CHECK: "1", }, stdio: ["ignore", "pipe", "pipe"], }); const tail = []; const take = (b) => { const s = b.toString(); tail.push(s); if (tail.length > 240) tail.shift(); onLine?.(s); }; child.stdout.on("data", take); child.stderr.on("data", take); return { child, tail, stop: () => { try { child.kill(); } catch {} killStrays(); } }; } function getJson(p) { return new Promise((resolve, reject) => { http.get(`http://127.0.0.1:${PORT}${p}`, (res) => { let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); }).on("error", reject); }); } export async function waitForTarget(match, ms = 40000) { const stop = Date.now() + ms; while (Date.now() < stop) { try { const hit = (await getJson("/json")).find(match); if (hit) return hit; } catch {} await sleep(250); } throw new Error(`CDP target not found within ${ms}ms`); } export class Cdp { constructor(wsUrl) { this.id = 0; this.pending = new Map(); this.listeners = []; this.ws = new WebSocket(wsUrl); this.ready = new Promise((res, rej) => { this.ws.addEventListener("open", () => res()); this.ws.addEventListener("error", () => rej(new Error("ws error"))); }); this.ws.addEventListener("message", (ev) => { const m = JSON.parse(typeof ev.data === "string" ? ev.data : Buffer.from(ev.data).toString("utf8")); if (m.id != null && this.pending.has(m.id)) { const { resolve, reject } = this.pending.get(m.id); this.pending.delete(m.id); m.error ? reject(new Error(m.error.message)) : resolve(m.result); } else for (const fn of this.listeners) fn(m); }); } on(fn) { this.listeners.push(fn); } send(method, params = {}) { const id = ++this.id; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); } async ev(expression) { const r = await this.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) { throw new Error("eval: " + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); } return r.result?.value; } /** Collect console errors and uncaught exceptions into `sink`. */ async watchErrors(sink) { await this.send("Runtime.enable"); this.on((m) => { if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") { sink.push(m.params.args.map((a) => a.value ?? a.description).join(" ")); } else if (m.method === "Runtime.exceptionThrown") { const d = m.params.exceptionDetails; sink.push("EXCEPTION " + (d.exception?.description || d.text)); } }); } close() { try { this.ws.close(); } catch {} } } export async function until(cdp, expr, ms = 20000, label = expr) { const stop = Date.now() + ms; let last; while (Date.now() < stop) { try { last = await cdp.ev(expr); if (last) return last; } catch (e) { last = e.message; } await sleep(200); } throw new Error(`timed out waiting for ${label} (last: ${JSON.stringify(last)?.slice(0, 200)})`); } export async function setFileInput(cdp, selector, absPath) { await cdp.send("DOM.enable"); const { root } = await cdp.send("DOM.getDocument", { depth: -1 }); const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector }); if (!nodeId) throw new Error(`no node for ${selector}`); await cdp.send("DOM.setFileInputFiles", { files: [absPath], nodeId }); }