theseus/addon-build/docx-editor/test/harness.mjs
Local Dev 56b3f2f206 feat(docx-editor): ship it in the build, updated over the first-party channel
Moving it out of the build left it with no way in. Settings can only install
from the community catalogue, so a first-party extension that isn't bundled
has a working update channel and no first copy for anyone to update — the
mechanism was all there and the front door was missing.

So it goes back beside screenshot, aegis and pdf-editor: seeded into every
profile by the build, listed under "Built into Theseus", and kept current
between releases by the operator-signed channel at
theseus.x/extensions/docx-editor/. That is the arrangement docs/ADDON-UPDATES.md
describes, and the one the signing script was written for.

About 400 KB compressed in the installer, most of it the vendored editor
libraries — next to the ~4 MB of pdf.js that pdf-editor already ships, the
weight argument for keeping it out didn't survive contact with the numbers.

The end-to-end driver goes back to checking that a fresh profile seeds it,
which is the property that actually matters now.
2026-09-21 22:47:13 +02:00

124 lines
4.8 KiB
JavaScript

// Loads the extension's libraries the way the browser does, but under node, so
// the round-trip can be tested without driving a browser.
//
// The only difference from the real thing is where the vendored packages come
// from: the browser gets them out of vendor/docx-vendor.js, node gets them
// from node_modules — with mammoth taken from .patched/, the same patched copy
// that goes into the bundle.
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
import vm from "node:vm";
import { JSDOM } from "jsdom";
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
export const ADDON = path.resolve(here, "../../../bundled-addons/docx-editor");
export function loadAddonLibs() {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.DOMParser = dom.window.DOMParser;
globalThis.XMLSerializer = dom.window.XMLSerializer;
globalThis.Node = dom.window.Node;
globalThis.DOCXV = {
mammoth: require(path.resolve(here, "../.patched/mammoth")),
docx: require("docx"),
JSZip: require("jszip"),
pm: {
state: require("prosemirror-state"),
view: null, // not needed outside the browser
model: require("prosemirror-model"),
schemaBasic: require("prosemirror-schema-basic"),
schemaList: require("prosemirror-schema-list"),
tables: require("prosemirror-tables"),
history: require("prosemirror-history"),
commands: require("prosemirror-commands"),
keymap: require("prosemirror-keymap"),
inputrules: require("prosemirror-inputrules"),
dropcursor: null,
gapcursor: null,
},
};
globalThis.DocxEditor = {};
for (const f of ["pkg.js", "schema.js", "read.js", "write.js"]) {
const src = readFileSync(path.join(ADDON, "lib", f), "utf8");
vm.runInThisContext(src, { filename: path.join(ADDON, "lib", f) });
}
return globalThis.DocxEditor;
}
// A compact, comparable view of a ProseMirror document: node types, the
// attributes that came from Word, and each text node's marks. Positions and
// ids are left out so the diff shows content drift and nothing else.
export function summarise(doc) {
const MEANINGFUL = ["level", "align", "indent", "lineHeight", "spaceBefore", "spaceAfter",
"format", "order", "colspan", "rowspan", "noteType", "noteId",
"alt", "width", "height"];
function attrs(node) {
const out = {};
for (const k of MEANINGFUL) {
const v = node.attrs && node.attrs[k];
if (v === undefined || v === null) continue;
if ((k === "colspan" || k === "rowspan" || k === "order") && v === 1) continue;
if (k === "indent" && v === 0) continue;
out[k] = v;
}
return out;
}
function walk(node) {
if (node.isText) {
const marks = node.marks.map((m) => {
const a = Object.keys(m.attrs || {})
.filter((k) => m.attrs[k] !== null && m.attrs[k] !== undefined)
.sort()
.map((k) => `${k}=${m.attrs[k]}`)
.join(",");
return a ? `${m.type.name}(${a})` : m.type.name;
}).sort();
return { t: "text", text: node.text, marks };
}
const entry = { t: node.type.name };
const a = attrs(node);
if (Object.keys(a).length) entry.a = a;
if (node.type.name === "image") {
// Compare the bytes by length, not by the whole base64 blob.
entry.a = Object.assign(entry.a || {}, { srcLen: (node.attrs.src || "").length });
}
const kids = [];
node.forEach((child) => kids.push(walk(child)));
if (kids.length) entry.c = kids;
return entry;
}
return walk(doc);
}
export function diff(a, b, pathStr = "doc", out = []) {
const ja = JSON.stringify(a), jb = JSON.stringify(b);
if (ja === jb) return out;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
out.push(`${pathStr}: ${ja} -> ${jb}`);
return out;
}
if (Array.isArray(a) !== Array.isArray(b)) {
out.push(`${pathStr}: shape changed`);
return out;
}
if (Array.isArray(a)) {
if (a.length !== b.length) out.push(`${pathStr}: ${a.length} children -> ${b.length}`);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if (i >= a.length) { out.push(`${pathStr}[${i}]: added ${JSON.stringify(b[i]).slice(0, 120)}`); continue; }
if (i >= b.length) { out.push(`${pathStr}[${i}]: lost ${JSON.stringify(a[i]).slice(0, 120)}`); continue; }
diff(a[i], b[i], `${pathStr}[${i}]`, out);
}
return out;
}
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
diff(a[k], b[k], `${pathStr}.${k}`, out);
}
return out;
}