From 8a08879c49d4372287d8a58c47143954a07d1475 Mon Sep 17 00:00:00 2001 From: Hermes Dev Date: Wed, 28 Oct 2026 22:31:57 +0200 Subject: [PATCH] test(docx-editor): the end-to-end suite moves into the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- addon-build/docx-editor/.gitignore | 5 + addon-build/docx-editor/package.json | 4 +- addon-build/docx-editor/test/e2e-harness.mjs | 180 +++++++++ addon-build/docx-editor/test/e2e.mjs | 363 +++++++++++++++++++ bundled-addons/docx-editor/README.md | 16 +- 5 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 addon-build/docx-editor/test/e2e-harness.mjs create mode 100644 addon-build/docx-editor/test/e2e.mjs diff --git a/addon-build/docx-editor/.gitignore b/addon-build/docx-editor/.gitignore index dae13e2d..f582a2cb 100644 --- a/addon-build/docx-editor/.gitignore +++ b/addon-build/docx-editor/.gitignore @@ -8,3 +8,8 @@ test/roundtrip-out.docx test/roundtrip-a.json test/roundtrip-b.json out/ + +# E2E scratch: a throwaway Theseus profile and its screenshot. +test/.profile/ +test/e2e-screenshot.png +test/*.docx diff --git a/addon-build/docx-editor/package.json b/addon-build/docx-editor/package.json index ee390505..42b5ccaa 100644 --- a/addon-build/docx-editor/package.json +++ b/addon-build/docx-editor/package.json @@ -7,7 +7,9 @@ "scripts": { "build": "node build.mjs", "icons": "node make-icons.mjs", - "fonts": "node fetch-fonts.mjs" + "fonts": "node fetch-fonts.mjs", + "test": "node test/roundtrip.mjs", + "test:e2e": "node test/e2e.mjs" }, "devDependencies": { "docx": "^9.5.1", diff --git a/addon-build/docx-editor/test/e2e-harness.mjs b/addon-build/docx-editor/test/e2e-harness.mjs new file mode 100644 index 00000000..ec3c91d5 --- /dev/null +++ b/addon-build/docx-editor/test/e2e-harness.mjs @@ -0,0 +1,180 @@ +// 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 }); +} diff --git a/addon-build/docx-editor/test/e2e.mjs b/addon-build/docx-editor/test/e2e.mjs new file mode 100644 index 00000000..0e692ab4 --- /dev/null +++ b/addon-build/docx-editor/test/e2e.mjs @@ -0,0 +1,363 @@ +// The Word editor, end to end, in a real Theseus. +// +// node test/e2e.mjs boot, run, quit +// node test/e2e.mjs --keep leave the instance up afterwards +// +// This is the only level that catches breakage which exists solely in the +// browser. The node round-trip (test/roundtrip.mjs) proves the reader and +// writer agree; it cannot prove that the vendored bundle loads as a classic +// script from file://, that the webfonts can actually be fetched there, that +// Chromium's print pipeline produces the paper size the document asked for, +// or that a ribbon button is wired to anything. Each of those has broken at +// least once, and each was caught here. +// +// Everything goes through the shipped surfaces: the sidebar panel's own file +// picker, the ribbon's real buttons, typed key events, and the editor's own +// . Nothing is stubbed and the editor carries no test-only hook, +// so a pass here is a pass for what users get. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { HERE, ROOT, Cdp, boot, waitForTarget, until, setFileInput, sleep } from "./e2e-harness.mjs"; +import { buildFixture } from "./fixture.mjs"; + +const KEEP = process.argv.includes("--keep"); +const DOWNLOADS = path.join(os.homedir(), "Downloads"); +const FIXTURE = path.join(HERE, "fixture.docx"); +const SECOND = path.join(HERE, "Second report.docx"); +const OUT = path.join(DOWNLOADS, "fixture-edited.docx"); +const OUT_PDF = path.join(DOWNLOADS, "fixture-edited.pdf"); + +const t0 = Date.now(); +const log = (...a) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s]`, ...a); + +const results = []; +function check(name, ok, detail) { + results.push({ name, ok: !!ok, detail }); + console.log(` ${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); + return !!ok; +} + +async function waitForFile(p, ms) { + const stop = Date.now() + ms; + while (Date.now() < stop) { + // A download in flight can exist at zero bytes; wait for the size to settle. + if (fs.existsSync(p) && fs.statSync(p).size > 0) { + const a = fs.statSync(p).size; + await sleep(400); + if (fs.statSync(p).size === a) return true; + } + await sleep(250); + } + return false; +} + +async function type(cdp, text) { + for (const ch of text) { + await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", text: ch, unmodifiedText: ch }); + await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", text: ch, unmodifiedText: ch }); + } + await sleep(150); +} + +async function main() { + for (const f of [OUT, OUT_PDF]) fs.rmSync(f, { force: true }); + fs.writeFileSync(FIXTURE, await buildFixture()); + fs.copyFileSync(FIXTURE, SECOND); + + const bundled = path.resolve(ROOT, "bundled-addons/docx-editor"); + check("ships inside the browser build", fs.existsSync(path.join(bundled, "addon.json")), + path.relative(ROOT, bundled)); + + log("booting Theseus with a fresh profile …"); + const { stop } = boot({ + onLine: (s) => { if (/\[addons\].*docx-editor/.test(s)) process.stdout.write(" · " + s.trim().split("\n")[0] + "\n"); }, + }); + + const pageErrors = []; + let editor = null; + try { + const chrome = await waitForTarget((t) => /chrome\.html$/.test(t.url)); + log("chrome up"); + const cdpChrome = new Cdp(chrome.webSocketDebuggerUrl); + await cdpChrome.ready; + + // The dock button is the user-visible proof that the build seeded the + // extension into a fresh profile and its manifest parsed. + const btn = await until(cdpChrome, + `(() => { const b = document.querySelector('#extbuttons [data-panel*="docx-editor"]'); + return b ? (b.title || "present") : null; })()`, + 30000, "the docx-editor dock button"); + check("a fresh profile seeds it and it gets a dock button", !!btn, String(btn)); + + const iconSrc = await cdpChrome.ev(`(() => { + const b = document.querySelector('#extbuttons [data-panel*="docx-editor"]'); + const img = b && b.querySelector("img"); + return img ? img.getAttribute("src").slice(0, 40) : (b ? "text:" + b.textContent.trim() : null); + })()`); + check("the dock shows our own icon, not an emoji", + typeof iconSrc === "string" && iconSrc.startsWith("data:image/svg+xml"), String(iconSrc)); + + log("opening the sidebar panel …"); + await cdpChrome.ev(`T.openSidebar("docx-editor:main")`); + const panelTarget = await waitForTarget((t) => /docx-editor[\/\\]panel\.html/.test(t.url), 20000); + const panel = new Cdp(panelTarget.webSocketDebuggerUrl); + await panel.ready; + await panel.watchErrors(pageErrors); + await until(panel, `document.readyState === "complete"`, 15000, "panel ready"); + check("sidebar panel loads", true); + + log("picking the fixture through the panel's file input …"); + await setFileInput(panel, "#file", FIXTURE); + const editorTarget = await waitForTarget((t) => /docx-editor[\/\\]editor\.html/.test(t.url), 25000); + check("choosing a .docx opens the editor tab", true, editorTarget.url.split("/").pop()); + + editor = new Cdp(editorTarget.webSocketDebuggerUrl); + await editor.ready; + await editor.watchErrors(pageErrors); + await until(editor, `document.readyState === "complete"`, 20000, "editor ready"); + + // The vendored bundle has to survive being loaded as a classic script + // from file:// — node tests cannot prove this. + const libs = await until(editor, + `(() => (window.DOCXV && window.DocxEditor && window.DocxEditor.read) ? "ok" : null)()`, + 20000, "vendor bundle + libraries"); + check("vendor bundle loads in the tab", libs === "ok"); + + const loaded = await until(editor, + `(() => { const n = document.querySelector('.ProseMirror'); return n && n.children.length > 3 ? n.children.length : null; })()`, + 30000, "the document to render"); + check("fixture renders", !!loaded, `${loaded} top-level blocks in the DOM`); + + // --- what the fixture is made of, as the editor sees it ------------- + const shape = await editor.ev(`(() => { + const pm = document.querySelector('.ProseMirror'); + const q = (s) => pm.querySelectorAll(s).length; + return { h1: q('h1'), h2: q('h2'), ul: q('ul'), ol: q('ol'), li: q('li'), + table: q('table'), td: q('td'), + // ProseMirror puts zero-width + // either side of an inline atom; those aren't content. + img: q('img:not(.ProseMirror-separator)'), + a: q('a'), hr: q('hr'), pageBreak: q('.docx-page-break'), + note: q('.docx-note-ref'), mark: q('mark'), sup: q('sup'), sub: q('sub') }; + })()`); + check("headings, lists and table came through", + shape.h1 >= 1 && shape.ol >= 1 && shape.ul >= 1 && shape.table === 1 && shape.td >= 8, + JSON.stringify(shape)); + check("image, link, rule, page break and footnote marker came through", + shape.img === 1 && shape.a >= 1 && shape.hr === 1 && shape.pageBreak === 1 && shape.note === 1); + check("highlight and sub/superscript came through", + shape.mark >= 1 && shape.sup >= 1 && shape.sub >= 1); + + const styling = await editor.ev(`(() => { + const el = [...document.querySelectorAll('.ProseMirror span[data-color]')][0]; + const f = [...document.querySelectorAll('.ProseMirror span[data-font]')][0]; + const s = [...document.querySelectorAll('.ProseMirror span[data-size]')][0]; + const c = [...document.querySelectorAll('.ProseMirror p')].find(p => p.style.textAlign === 'center'); + return { color: el && el.getAttribute('data-color'), font: f && f.getAttribute('data-font'), + size: s && s.getAttribute('data-size'), centred: !!c }; + })()`); + check("direct formatting survived the read", + styling.color === "008080" && styling.font === "Georgia" && styling.size === "16" && styling.centred, + JSON.stringify(styling)); + + // --- the page uses the window, and the bundled fonts are real ------- + const zoom = await editor.ev(`(() => { + const board = document.getElementById("board"); + const s = document.querySelector(".sheet").getBoundingClientRect(); + return { + mode: document.getElementById("zoom").value, + fill: Math.round((s.width / (board.clientWidth - 32)) * 100), + }; + })()`); + check("the page fills the width by default", zoom.mode === "fit" && zoom.fill >= 92, + JSON.stringify(zoom)); + + // document.fonts.check() is false for a face that is registered but not + // yet fetched, so ask for them first and then measure: a family that + // failed to load renders identically to the fallback, which is exactly + // what file:// does to @font-face if nothing inlines the woff2. + const fonts = await editor.ev(`(async () => { + const wanted = ["Ubuntu", "Fraunces"]; + await Promise.all(wanted.map((f) => document.fonts.load("400 40px " + f))); + await document.fonts.ready; + const measure = (family) => { + const el = document.createElement("span"); + el.textContent = "Handgloves \\u2014 \\u041F\\u0440\\u0438\\u0432\\u0435\\u0442"; + el.style.cssText = "position:absolute;visibility:hidden;font:400 40px " + family; + document.body.append(el); + const w = el.getBoundingClientRect().width; + el.remove(); + return Math.round(w * 100) / 100; + }; + const out = { loaded: {}, widths: { fallback: measure("NoSuchFontHere, serif") } }; + for (const f of wanted) { + out.loaded[f] = document.fonts.check("400 40px " + f); + out.widths[f] = measure(JSON.stringify(f) + ", NoSuchFontHere, serif"); + } + out.inMenu = [...document.getElementById("font-family").options].map((o) => o.value); + out.saysMissing = [...document.getElementById("font-family").options] + .filter((o) => /not available/.test(o.textContent)).map((o) => o.value); + return out; + })()`); + check("Ubuntu and Fraunces are offered in the ribbon", + fonts.inMenu.includes("Ubuntu") && fonts.inMenu.includes("Fraunces")); + check("and both really load from the add-on", + fonts.loaded.Ubuntu && fonts.loaded.Fraunces && !fonts.saysMissing.length); + check("text drawn in them differs from the fallback face", + fonts.widths.Ubuntu !== fonts.widths.fallback && + fonts.widths.Fraunces !== fonts.widths.fallback && + fonts.widths.Ubuntu !== fonts.widths.Fraunces, + JSON.stringify(fonts.widths)); + + // --- edit through the real UI --------------------------------------- + log("editing: click into the heading, type, insert a page break …"); + const pt = await editor.ev(`(() => { + const r = document.querySelector('.ProseMirror h1').getBoundingClientRect(); + return { x: Math.round(r.left + 8), y: Math.round(r.top + r.height / 2) }; + })()`); + for (const kind of ["mousePressed", "mouseReleased"]) { + await editor.send("Input.dispatchMouseEvent", { + type: kind, x: pt.x, y: pt.y, button: "left", clickCount: 1, + buttons: kind === "mousePressed" ? 1 : 0, + }); + } + await sleep(200); + for (const t of ["keyDown", "keyUp"]) { + await editor.send("Input.dispatchKeyEvent", { type: t, key: "End", code: "End", windowsVirtualKeyCode: 35 }); + } + await type(editor, " EDITED"); + const typed = await editor.ev(`document.querySelector('.ProseMirror h1').textContent`); + check("typing reaches the document", /EDITED$/.test(typed), JSON.stringify(typed)); + check("the unsaved-changes marker appears", + await editor.ev(`!!document.querySelector('#docname .dirty')`)); + + await editor.ev(`document.getElementById('i-pagebreak').click()`); + await sleep(250); + check("ribbon insert works", + await editor.ev(`document.querySelectorAll('.ProseMirror .docx-page-break').length`) === 2); + + const banner = await editor.ev(`(() => { + const b = document.querySelector('#banners .banner'); + return b ? b.querySelector('.body').textContent.trim().slice(0, 70) : null; + })()`); + check("the round-trip report is shown to the user", !!banner, String(banner)); + + // --- a second document becomes a tab, not a second browser tab ------ + log("opening a second document from the sidebar …"); + const tabsBefore = await cdpChrome.ev(`document.getElementById("tabs").children.length`); + await setFileInput(panel, "#file", SECOND); + await sleep(4000); + check("a second document does not open a second browser tab", + await cdpChrome.ev(`document.getElementById("tabs").children.length`) === tabsBefore); + + const tabs = await editor.ev(`(() => { + const els = [...document.querySelectorAll('#doctabs .dtab')]; + return { + names: els.map((e) => e.querySelector('.nm').textContent), + active: els.findIndex((e) => e.classList.contains('active')), + allHaveClose: els.every((e) => !!e.querySelector('.x')), + allHaveCaret: els.every((e) => !!e.querySelector('.caret')), + }; + })()`); + check("both documents are tabs in the one editor", tabs.names.length === 2, JSON.stringify(tabs.names)); + check("each document tab has its own close button and menu", + tabs.allHaveClose && tabs.allHaveCaret); + check("the newly opened document is the active tab", tabs.active === 1); + + const menu = await editor.ev(`(() => { + [...document.querySelectorAll('#doctabs .dtab')][1] + .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 200, clientY: 90 })); + const m = document.querySelector('.dmenu'); + return m ? [...m.querySelectorAll('button')].map((b) => b.textContent) : null; + })()`); + check("the tab menu offers duplicate / open in / close", + Array.isArray(menu) && menu.some((i) => /^Duplicate/.test(i)) && menu.some((i) => /^Open in/.test(i)), + JSON.stringify(menu)); + + await editor.ev(`[...document.querySelectorAll('.dmenu button')].find((x) => /^Duplicate/.test(x.textContent)).click()`); + await sleep(900); + const afterDup = await editor.ev(`[...document.querySelectorAll('#doctabs .dtab .nm')].map((e) => e.textContent)`); + check("duplicate adds a copy as its own tab", + afterDup.length === 3 && afterDup.some((n) => /\(copy\)/.test(n)), JSON.stringify(afterDup)); + + const swap = await editor.ev(`(() => { + const els = [...document.querySelectorAll('#doctabs .dtab')]; + els[0].click(); const first = document.querySelector('.ProseMirror h1').textContent; + els[1].click(); const second = document.querySelector('.ProseMirror h1').textContent; + els[0].click(); + return { first, second, back: document.querySelector('.ProseMirror h1').textContent }; + })()`); + check("each tab shows its own document", + /EDITED/.test(swap.first) && !/EDITED/.test(swap.second) && swap.back === swap.first, + JSON.stringify(swap)); + + await editor.ev(`[...document.querySelectorAll('#doctabs .dtab')][2].querySelector('.x').click()`); + await sleep(700); + check("the close button closes just that document", + await editor.ev(`document.querySelectorAll('#doctabs .dtab').length`) === 2); + + // --- save and export ------------------------------------------------- + log("saving through the editor's own Save button …"); + await editor.ev(`document.getElementById('file-save').click()`); + const saved = await waitForFile(OUT, 30000); + check("Save produces a .docx in Downloads", saved, + saved ? `${(fs.statSync(OUT).size / 1024).toFixed(0)} KB` : "no file"); + if (saved) { + const status = await editor.ev(`document.getElementById('msg').textContent`); + check("the save reports what it carried over", /carried over/.test(status), status.slice(0, 110)); + } + + log("exporting a PDF through the ribbon …"); + await editor.ev(`document.getElementById('file-pdf').click()`); + const gotPdf = await waitForFile(OUT_PDF, 45000); + check("PDF export produces a file", gotPdf, + gotPdf ? `${(fs.statSync(OUT_PDF).size / 1024).toFixed(0)} KB` : "no file"); + if (gotPdf) { + const text = fs.readFileSync(OUT_PDF).toString("latin1"); + check("it is a real PDF", text.startsWith("%PDF-")); + // Chromium writes the page box in points: A4 is 595 x 842, and the + // fixture is A4, so this proves the @page rule built from the .docx + // section setup reached the print pipeline. + const box = /\/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)/.exec(text); + check("the PDF is on the document's own paper size", + !!box && Math.round(+box[1]) === 595, + box ? `${Math.round(+box[1])} x ${Math.round(+box[2])} pt` : "no MediaBox"); + check("the page break split the PDF", + (text.match(/\/Type\s*\/Page[^s]/g) || []).length >= 2); + const faces = [...new Set([...text.matchAll(/\/BaseFont\s*\/([A-Za-z0-9+#-]+)/g)].map((m) => m[1]))]; + check("the PDF embeds the fonts it was drawn with", + faces.length > 0 && faces.every((f) => /\+/.test(f)), faces.join(", ").slice(0, 110)); + } + } finally { + if (editor) { + const shot = await editor.send("Page.captureScreenshot", { format: "png" }).catch(() => null); + if (shot) fs.writeFileSync(path.join(HERE, "e2e-screenshot.png"), Buffer.from(shot.data, "base64")); + } + fs.rmSync(SECOND, { force: true }); + if (!KEEP) stop(); + } + + // --- read the saved file back with the add-on's own reader ------------- + if (fs.existsSync(OUT)) { + const { loadAddonLibs } = await import("./harness.mjs"); + const DocxEditor = loadAddonLibs(); + const schema = DocxEditor.schema.build(); + const bytes = new Uint8Array(fs.readFileSync(OUT)); + const back = await DocxEditor.read.docxToDoc(bytes, schema); + check("the saved file reopens", back.doc.childCount > 10, `${back.doc.childCount} blocks`); + check("the edit is in the saved file", /EDITED/.test(back.doc.textContent)); + const names = Object.keys((await DocxEditor.pkg.loadZip(bytes)).files); + check("headers, footers and footnotes are in the saved package", + ["word/header1.xml", "word/footer1.xml", "word/footnotes.xml"].every((n) => names.includes(n))); + } + + const errors = pageErrors.filter((e) => !/favicon|Autofill|devtools/i.test(e)); + check("no page errors", errors.length === 0, errors.slice(0, 2).join(" | ")); + + const failed = results.filter((r) => !r.ok); + console.log(`\n${results.length - failed.length}/${results.length} checks passed`); + process.exit(failed.length ? 1 : 0); +} + +main().catch((e) => { console.error("\nDRIVER ERROR:", e); process.exit(2); }); diff --git a/bundled-addons/docx-editor/README.md b/bundled-addons/docx-editor/README.md index a3a59698..dfe92e1b 100644 --- a/bundled-addons/docx-editor/README.md +++ b/bundled-addons/docx-editor/README.md @@ -104,13 +104,19 @@ just its own entry. Versions must increase. Three levels, all re-runnable, all from `../../addon-build/docx-editor`: - node test/roundtrip.mjs # the built-in fixture + npm test # the built-in fixture, round-tripped node test/corpus.mjs # real documents - node ../../../scratchpad/verify-docx-editor/drive.mjs # a real Theseus, over CDP + npm run test:e2e # a real Theseus, driven over CDP -The last one installs this folder into a throwaway profile the way the -community channel would, and is the only one that catches browser-only -breakage. +The last one boots a throwaway Theseus, opens documents through the sidebar, +clicks the real ribbon, saves and exports, and reads the result back — about +forty checks. It is the only level that catches breakage which exists solely +in the browser: the vendored bundle failing to load as a classic script from +`file://`, webfonts that register but never fetch, a ribbon button wired to +nothing, a PDF on the wrong paper. Each of those has happened. + +It used to live in `scratchpad/`, which is untracked, and was deleted by a +tidy-up on 2026-10-22 along with the pdf-editor's copy. It lives here now. What survives a round trip, and what doesn't, is written up in [ROUND-TRIP.md](ROUND-TRIP.md). Read that before promising anyone a Word