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