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.
180 lines
7.2 KiB
JavaScript
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 });
|
|
}
|