theseus/addon-build/docx-editor/test/e2e-harness.mjs
Hermes Dev 8a08879c49 test(docx-editor): the end-to-end suite moves into the repo
It lived in scratchpad/, which is untracked, and a tidy-up on 2026-10-22
deleted it — along with the pdf-editor's copy of the same harness. Forty
checks of coverage gone, and nothing in the repo so much as mentioned they
had existed. Anything load-bearing belongs next to the code it tests.

Rebuilt as test/e2e.mjs plus test/e2e-harness.mjs, runnable with
`npm run test:e2e`. It boots a throwaway Theseus, opens documents through
the sidebar panel, clicks the real ribbon, types with real key events, saves
and exports, then reads the result back with the add-on's own reader. The
editor carries no test-only hook, so a pass is a pass for what users get.

This is the only level that catches breakage which exists solely in the
browser — a bundle that will not load as a classic script from file://,
webfonts that register and then never fetch, a ribbon button wired to
nothing, a PDF on the wrong paper. All four have happened.

One trap is recorded in the harness: the throwaway profile must not live
under addon-build/, whose package.json declares "type": "module". Node picks
a file's module kind from the nearest package.json above it, so an add-on
seeded into a profile there is parsed as ESM and dies on its first require()
with "require is not defined in ES module scope" — which reads exactly like
the add-on being broken, and is not.

Run against today's build: 38/38, including the new start-on-first-use
activation.
2026-10-28 22:31:57 +02:00

180 lines
7.2 KiB
JavaScript

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