Files
Steffen Becker 055839655e test: the monkey edits both zones, and the runs compare what is drawn
Header and footer go first, each with its own ops, and a variant flag is
flipped while a zone is open. The round trip compares a list's drawn kind and
marker rather than its node type and style name — a list that loses its name to
a formatted marker carries the style's own char — and skips the marks of an
inline atom, which no file keeps. A caret is snapped to a text position, a
swallowed File-menu click is retried, and WebKit's converging ResizeObserver
round is no page error. A new corpus pair brings all six zones through the
import path; the baseline holds it and a re-recorded showcase entry.
2026-09-06 19:07:23 +02:00

540 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Monkey run: random editing in the real browser under the invariants any sequence of
// commands must keep — no uncaught error, a document its schema accepts, undo back to the
// opened file and redo forward, and a saved file that validates and reads back as held.
import { readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { unzipSync } from 'fflate';
import { JSDOM } from 'jsdom';
import { ROOT, MOD, checker, devServer, openApp } from '../browser.mjs';
import { hasXmllint, validateOdt, validateDocx } from '../schemaValidate.ts';
// The schema helper strips foreign markup with the browser's DOM parser.
Object.assign(globalThis, (({ DOMParser, XMLSerializer }) => ({ DOMParser, XMLSerializer }))(new JSDOM('').window));
const PORT = +(process.env.MONKEY_PORT ?? 4189);
const SEED = +(process.env.MONKEY_SEED ?? 1);
const RUNS = +(process.env.MONKEY_RUNS ?? 3);
const OPS = +(process.env.MONKEY_OPS ?? 60);
const HF_OPS = +(process.env.MONKEY_HF_OPS ?? 8);
const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '16-hf-variants.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt'];
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC';
const { check, failures } = checker();
if (!hasXmllint) console.log('xmllint missing: the saved files are not validated against the schemas');
// fuzzDoc.ts's generator, so a seed replays.
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const pick = (r, arr) => arr[Math.floor(r() * arr.length)];
const int = (r, min, max) => min + Math.floor(r() * (max - min + 1));
const WORDS = ['lorem ', 'Ipsum', ' dolor sit ', 'äöü ', '42', '. ', 'A', ' — ', 'x'];
const KEYS = ['Enter', 'Enter', 'Backspace', 'Backspace', 'Delete', 'Tab', 'Shift+Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
'Shift+ArrowLeft', 'Shift+ArrowRight', 'Shift+ArrowDown', 'Home', 'End', `${MOD}+b`, `${MOD}+i`, `${MOD}+u`, `${MOD}+Shift+7`, `${MOD}+Shift+8`,
`${MOD}+Enter`, `${MOD}+z`, `${MOD}+Shift+z`];
const STYLES = ['Standard', 'Heading 1', 'Heading 2', 'Heading 3', 'Title', 'Quotations'];
const WRAPS = ['inline', 'left', 'right', 'topBottom', 'through'];
// Each entry: a weight and a draw of the op — a key, typed text, or an editor command.
const OPS_TABLE = [
[24, (r) => ({ type: pick(r, WORDS) + (r() < 0.3 ? pick(r, WORDS) : '') })],
[20, (r) => ({ key: pick(r, KEYS) })],
[10, (r, size) => ({ cmd: 'focus', args: [int(r, 1, Math.max(1, size - 2))] })],
[6, (r, size) => { const a = int(r, 1, Math.max(1, size - 2)), b = int(r, 1, Math.max(1, size - 2)); return { cmd: 'setTextSelection', args: [{ from: Math.min(a, b), to: Math.max(a, b) }] }; }],
[8, (r) => pick(r, [
{ cmd: 'toggleBold' }, { cmd: 'toggleItalic' }, { cmd: 'toggleUnderline' }, { cmd: 'toggleStrike' },
{ cmd: 'toggleSubscript' }, { cmd: 'toggleSuperscript' }, { cmd: 'setColor', args: ['#c00000'] },
{ cmd: 'setFontSize', args: ['14pt'] }, { cmd: 'setFontFamily', args: ['Liberation Sans'] }, { cmd: 'unsetAllMarks' },
])],
[12, (r) => pick(r, [
{ cmd: 'setParagraphStyle', args: [pick(r, STYLES)] }, { cmd: 'setTextAlign', args: [pick(r, ['left', 'center', 'right', 'justify'])] },
{ cmd: 'indentMore' }, { cmd: 'indentLess' }, { cmd: 'setLineHeight', args: ['1.5'] }, { cmd: 'setSpaceBefore', args: [6] },
{ cmd: 'toggleBulletList' }, { cmd: 'toggleOrderedList' }, { cmd: 'sinkListItem', args: ['listItem'] }, { cmd: 'liftListItem', args: ['listItem'] },
{ cmd: 'insertPageBreak' }, { cmd: 'setColumns', args: [2] }, { cmd: 'setColumns', args: [1] }, { cmd: 'clearDirectFormatting' },
])],
[8, (r) => pick(r, [
{ cmd: 'insertTable', args: [{ rows: int(r, 2, 3), cols: int(r, 2, 3), withHeaderRow: r() < 0.5 }] },
{ cmd: 'addRowAfter' }, { cmd: 'addColumnAfter' }, { cmd: 'deleteRow' }, { cmd: 'deleteColumn' },
{ cmd: 'mergeCells' }, { cmd: 'splitCell' }, { cmd: 'deleteTable' }, { cmd: 'goToNextCell' },
])],
[8, (r) => pick(r, [
{ cmd: 'insertNote', args: ['footnote'] }, { cmd: 'insertNote', args: ['endnote'] },
{ cmd: 'insertFormula', args: [{ latex: 'x^{2}+1', display: false }] }, { cmd: 'insertTextBox' },
{ cmd: 'setImage', args: [{ src: PNG, width: 80, height: 60 }] }, { cmd: 'setHardBreak' },
{ cmd: 'addComment', args: [{ author: 'Monkey', text: 'hm' }] }, { cmd: 'insertContent', args: ['—'] },
])],
[8, (r) => pick(r, [
{ cmd: 'setImage', args: [{ src: PNG, width: int(r, 40, 220), height: int(r, 30, 160), wrap: pick(r, WRAPS) }] },
{ cmd: 'setImageWrap', args: [pick(r, WRAPS)] }, { cmd: 'insertTextBox' },
{ cmd: 'setTextBoxAttrs', args: [{ width: int(r, 60, 280), height: int(r, 40, 200) }] },
{ cmd: 'setTextBoxAttrs', args: [{ wrap: pick(r, WRAPS), wrapDist: pick(r, [0, 0.2]) }] },
{ cmd: 'setTextBoxAlign', args: [pick(r, ['left', 'center', 'right', null])] },
{ cmd: 'setColumns', args: [int(r, 2, 3)] }, { cmd: 'setColumnGap', args: [pick(r, [0.5, 1.5])] },
])],
[3, (r) => ({ cmd: pick(r, ['undo', 'redo']) })],
];
const TOTAL = OPS_TABLE.reduce((n, [w]) => n + w, 0);
const drawOp = (r, size) => {
let x = r() * TOTAL;
for (const [w, draw] of OPS_TABLE) { if ((x -= w) < 0) return draw(r, size); }
return { key: 'ArrowRight' };
};
// The zone's own schema — one paragraph of runs, breaks, fields and inline images — so it
// draws from a table of its own. No Escape (that leaves), no undo: a pass falls into one
// history group, so one Mod+Z would empty the zone before the round trip ever sees it.
const HF_KEYS = ['Enter', 'Backspace', 'Backspace', 'Delete', 'Tab', 'Home', 'End',
'ArrowLeft', 'ArrowRight', 'Shift+ArrowLeft', 'Shift+ArrowRight', `${MOD}+b`, `${MOD}+i`, `${MOD}+u`];
const HF_OPS_TABLE = [
[14, (r) => ({ type: pick(r, WORDS) })],
[12, (r) => ({ key: pick(r, HF_KEYS) })],
// The insert bar's three field buttons, in its order: page number, count, chapter.
[5, (r) => ({ field: int(r, 0, 2) })],
[9, (r) => pick(r, [
{ cmd: 'toggleBold' }, { cmd: 'toggleItalic' }, { cmd: 'toggleUnderline' }, { cmd: 'toggleStrike' },
{ cmd: 'unsetAllMarks' }, { cmd: 'setTextAlign', args: [pick(r, ['left', 'center', 'right'])] },
{ cmd: 'setFontSize', args: ['10pt'] }, { cmd: 'setColor', args: ['#c00000'] },
{ cmd: 'setImage', args: [{ src: PNG, width: 24, height: 18 }] },
])],
];
const HF_TOTAL = HF_OPS_TABLE.reduce((n, [w]) => n + w, 0);
const drawHfOp = (r) => {
let x = r() * HF_TOTAL;
for (const [w, draw] of HF_OPS_TABLE) { if ((x -= w) < 0) return draw(r); }
return { key: 'ArrowRight' };
};
const server = await devServer(PORT);
const { browser, page, pageErrors } = await openApp(PORT);
const consoleErrors = [];
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
const ed = (fn, arg) => page.evaluate(fn, arg);
const focused = async () => {
const err = await ed(() => { try { document.querySelector('.tiptap-host .tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } });
await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap-host .tiptap'), null, { timeout: 2000 }).catch(() => {});
return err;
};
const runCmd = (op) => ed(({ cmd, args }) => {
const editor = document.querySelector('.tiptap-host .tiptap').editor;
// A caret only ever sits in a text block — the view puts it nowhere else — but
// focus(pos) makes the selection wherever it is told, and Enter on one between two
// list items throws out of prosemirror. Snap to the next text position instead.
if (cmd === 'focus' && typeof args?.[0] === 'number') {
const doc = editor.state.doc;
let at = Math.max(0, Math.min(args[0], doc.content.size));
for (let n = at; n <= doc.content.size; n++) if (doc.resolve(n).parent.isTextblock) { at = n; break; }
args = [at];
}
try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; }
}, op);
// The schema's own check of the document after every op — a command may leave a node
// with content its spec rejects, which nothing else reports.
const docCheck = () => ed(() => {
try { document.querySelector('.tiptap-host .tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); }
});
const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON()));
// The key the history is held against, and the count of the columns flow's own rewrites:
// the flow changes the document outside the history (`FLOW_TX`, columnsFlow.ts), so undo
// rebases over it — ponytail: such a run has its history left unchecked.
const watchFlow = () => ed(async () => {
const { mergeJoinedParagraphsJson } = await import('/src/lib/export/odt.ts');
// The caches the editor recomputes off the history — a note's label, an index's rows, a
// caption's number, a reference's text — plus a paragraph columnsFlow split at a page
// boundary: layout and derived values, not the edits undo has to reverse.
const DERIVED = { noteRef: 'text', note: 'text', chapterField: 'text', crossReference: 'text',
bibliographyEntry: 'text', sequenceField: 'number', tableOfContents: 'entries' };
const strip = (n) => {
if (n.attrs && DERIVED[n.type]) n.attrs = { ...n.attrs, [DERIVED[n.type]]: null };
for (const c of n.content ?? []) strip(c);
return n;
};
window.__docKey = () => JSON.stringify(
mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap-host .tiptap').editor.getJSON()).content ?? []));
const view = document.querySelector('.tiptap-host .tiptap').editor.view;
window.__flowTx = 0;
if (view.__flowWatched) return;
view.__flowWatched = true;
const dispatch = view.dispatch.bind(view);
view.dispatch = (tr) => {
if (tr.docChanged && tr.getMeta('columnsFlowTx')) window.__flowTx++;
return dispatch(tr);
};
});
const docKey = () => ed(() => window.__docKey());
const docSize = () => ed(() => document.querySelector('.tiptap-host .tiptap').editor.state.doc.content.size);
const HF_LIVE = '.hf-active .tiptap';
const hfCmd = (op) => ed(({ cmd, args }) => {
const editor = document.querySelector('.hf-active .tiptap')?.editor;
if (!editor) return { threw: 'the zone editor is gone' };
try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; }
}, op);
const hfCheck = () => ed(() => {
const editor = document.querySelector('.hf-active .tiptap')?.editor;
if (!editor) return 'the zone editor is gone';
try { editor.state.doc.check(); return null; } catch (e) { return String(e); }
});
// A floating toolbar of the body's — a selected frame's, say — can sit over the zone
// and its bar; the handler behind the element is what this run is after, not hit-testing.
const clickish = (at, how = 'click') => at[how]({ timeout: 4000 })
.catch(() => at.dispatchEvent(how, {}, { timeout: 4000 }))
.catch(() => false); // gone already (an op closed the zone): the checks after it still speak
// A checkbox in the ribbon's own menu: the menu re-renders under the click (the flag
// ends an open zone edit), so a real click can find the element detached.
const setBox = (at, on) => at.setChecked(on, { timeout: 4000 })
.catch(() => at.evaluate((el, v) => { if (el.checked !== v) el.click(); }, on));
// The two variant flags and the zone distances, from the ribbon's Insert tab. Setting
// them here is what puts the first-page and even-page zones in play on a document that
// carries none of its own.
async function setHfOptions(r) {
await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click();
const options = page.locator('button.rb', { hasText: 'Options' }).first();
await clickish(options);
await page.waitForSelector('.ribbon-menu .check-row input', { timeout: 5000 });
const boxes = page.locator('.ribbon-menu .check-row input');
for (const i of [0, 1]) await setBox(boxes.nth(i), r() < 0.6);
if (r() < 0.4) {
const dist = page.locator('.ribbon-menu .num-row input').nth(int(r, 0, 1));
await dist.fill(String(pick(r, [0.6, 1, 1.8])));
await dist.dispatchEvent('change');
}
await clickish(options);
}
// Flipping a flag ends an open zone edit (App.svelte drops hfActive with it), which
// tears the live editor down mid-session — what was typed must be in the zones anyway.
async function flipUnderEdit(mark) {
const zone = page.locator('.hf-zone.hf-footer').first();
if (!(await zone.count())) return null;
await clickish(zone, 'dblclick');
await page.waitForSelector(HF_LIVE, { timeout: 5000 });
await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {});
await page.keyboard.type(mark);
await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click();
const options = page.locator('button.rb', { hasText: 'Options' }).first();
await clickish(options);
const box = page.locator('.ribbon-menu .check-row input').nth(1);
await setBox(box, !(await box.isChecked()));
await clickish(options);
const gone = await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).then(() => true, () => false);
const kept = await ed((m) => ['edentext-header', 'edentext-footer', 'edentext-header-first',
'edentext-footer-first', 'edentext-header-even', 'edentext-footer-even', 'edentext-hf-sections']
.some((k) => (localStorage.getItem(k) ?? '').includes(m)), mark);
if (!gone) return 'the flag left the zone editor open';
return kept ? null : 'the text typed before the flag flipped is in no zone';
}
// A zone edited the way a user reaches it: double-click in, edit, leave by Done. Pages
// 1 to 3 in turn — with the two variant flags on that is one session per variant, the
// title page's, an even page's and a running one's. Only the round trip reports on it.
async function editZone(r, zone) {
const zones = page.locator(`.hf-zone.hf-${zone}`);
const n = await zones.count();
if (!n) return { ops: [], broken: `no ${zone} zone on the page` };
const ops = [];
let broken = null;
for (let p = 0; p < Math.min(3, n) && !broken; p++) {
await clickish(zones.nth(p), 'dblclick');
await page.waitForSelector(HF_LIVE, { timeout: 5000 });
// The mount focuses itself a tick later; typing before that loses its first keys.
await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {});
for (let i = 0; i < HF_OPS && !broken; i++) {
const op = { page: p + 1, ...drawHfOp(r) };
ops.push(op);
if (op.type) await page.keyboard.type(op.type);
else if (op.key) await page.keyboard.press(op.key);
else if (op.field !== undefined) await clickish(page.locator('.hf-bar-btn').nth(op.field));
else {
const res = await hfCmd(op);
if (res.threw) broken = `${op.cmd} threw: ${res.threw}`;
}
const invalid = broken ? null : await hfCheck();
if (invalid) broken = `schema-invalid ${zone} on page ${p + 1}: ${invalid}`;
if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`;
}
await clickish(page.locator('.hf-bar-done'));
await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).catch(() => {});
}
return { ops, broken };
}
const saveAs = async (ext, retry = true) => {
await page.click('.ribbon-tab-file');
try {
const [dl] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(),
]);
await page.keyboard.press('Escape');
return new Uint8Array(await readFile(await dl.path()));
} catch (err) {
// The File menu can miss a click while the app is still laying the document out, and
// no download follows. One more go; a second miss is a finding.
if (!retry) throw err;
await page.keyboard.press('Escape');
return saveAs(ext, false);
}
};
// The file the app saved, read back by the app's importer against the editor's document
// (its paragraphs joined across a column or page merged, as the export merges them).
const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }, hfStore, { effectiveListLevel, defaultLevelBullet }] = await Promise.all([
import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts'),
import('/src/lib/storage/headerFooter.ts'), import('/src/lib/styles/listStyles.ts')]);
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const res = ext === 'odt' ? importOdt(bin) : importDocx(bin);
const editor = document.querySelector('.tiptap-host .tiptap').editor;
const json = editor.getJSON();
// What the importer suppresses as the style's own: a block attribute equal to what its
// named style resolves to, and bold in a header cell.
const { resolveStyle } = await import('/src/lib/styles/styleSheet.ts');
const sheet = editor.extensionManager.extensions.find((e) => e.name === 'paragraphStyle')?.options.sheet();
const styled = (n, header = false, framed = false, text = {}) => {
// A heading's level already names its style, so neither importer writes one out —
// and the style it resolves to is the one whose values are suppressed.
const style = n.attrs?.styleName ?? (n.type === 'heading' ? `Heading ${n.attrs?.level ?? 1}` : null);
if (style && sheet) {
const own = resolveStyle(sheet, style, 'paragraph');
for (const [k, v] of Object.entries(n.attrs)) if (k !== 'styleName' && own.para?.[k] === v) delete n.attrs[k];
text = own.text ?? {};
}
if (header && n.type === 'text' && n.marks) n.marks = n.marks.filter((m) => m.type !== 'bold');
// A formula alone in its block is a display formula to both files — a note holds its
// inline content without a paragraph, so it is a block here too.
if ((n.type === 'paragraph' || n.type === 'note') && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true };
// A box with no size of its own is saved at the size the exporter falls back to, and
// Word's table grid always carries widths, so a merged cell reads back with them.
if (n.type === 'textBox') n.attrs = { ...n.attrs, width: n.attrs?.width ?? 280, height: n.attrs?.height ?? 96 };
if (n.attrs?.colwidth) delete n.attrs.colwidth;
// Both formats spell a header row as the one that repeats, so it reads back repeating.
if (n.type === 'table' && n.content?.[0]?.content?.[0]?.type === 'tableHeader') delete n.attrs?.repeatHeader;
if (n.attrs?.styleName === style || n.attrs?.styleName === 'Standard') delete n.attrs.styleName;
// Same for bold and italic: a heading is already both where its style says so, and
// a mark repeating that is what the importers drop.
if (n.type === 'text' && n.marks) n.marks = n.marks.filter((m) =>
!((m.type === 'bold' && text.bold) || (m.type === 'italic' && text.italic)));
// A run repeating what its own style says is direct formatting both importers
// suppress — they cannot tell it from a style that failed to resolve.
for (const m of n.type === 'text' ? n.marks ?? [] : []) {
if (m.type !== 'textStyle' || !m.attrs) continue;
if (m.attrs.fontFamily === (text.fontFamily ?? 'Liberation Serif')) delete m.attrs.fontFamily;
if (parseFloat(m.attrs.fontSize) === (text.fontSizePt ?? 12)) delete m.attrs.fontSize;
}
// Word has no anchored drawing inside a text box, so a frame in one goes out inline.
if (framed && ext === 'docx' && (n.type === 'image' || n.type === 'textBox') && n.attrs) n.attrs = { ...n.attrs, wrap: null, inFront: null };
// A block in a box or a cell carries no style name (import/CLAUDE.md), so its chain
// is baked in and a heading there reads back bold instead of styled.
if (framed && n.type === 'heading') for (const c of n.content ?? []) if (c.marks) c.marks = c.marks.filter((m) => m.type !== 'bold');
const inFrame = framed || n.type === 'textBox' || n.type === 'tableCell' || n.type === 'tableHeader';
for (const c of n.content ?? []) styled(c, header || n.type === 'tableHeader', inFrame, text);
return n;
};
// The exporter folds a columns section's page-split chain back into one node and merges
// the flow-split paragraphs inside it (replaceColumns, odt.ts); so must the held side.
const foldColumns = (blocks) => {
const out = [];
for (const b of mergeJoinedParagraphsJson(blocks)) {
const prev = out[out.length - 1];
if (b.type !== 'columns') { out.push(b); continue; }
if (prev?.type === 'columns' && JSON.stringify(prev.attrs) === JSON.stringify(b.attrs)) {
prev.content = mergeJoinedParagraphsJson([...(prev.content ?? []), ...(b.content ?? [])]);
} else out.push({ ...b, content: mergeJoinedParagraphsJson(b.content ?? []) });
}
return out;
};
// A list's type is only what its marker draws: where the document's own list style
// governs the depth, that level's kind decides it (listStyles.ts) and neither file
// carries a node type beside it. Both sides compare the drawn kind.
const effListKinds = (n, style = null, depth = 0) => {
const list = n.type === 'bulletList' || n.type === 'orderedList';
const own = list && depth === 0 ? sheet?.list?.[n.attrs?.listStyleName] ?? null : style;
if (list) {
const eff = effectiveListLevel(n.attrs ?? {}, n.type === 'orderedList', own, depth + 1);
n.type = eff.kind === 'number' ? 'orderedList' : 'bulletList';
// The name is not always written: direct formatting on a marker makes the list keep
// its resolved automatic clone instead (odt.ts), and a nested list's own name is only
// the memory for a later lift out. corpus.test.ts holds the plain round trip.
// Its marker then reads back as the direct char the style drew, so both sides carry
// the drawn one — the same model the kind above compares by.
n.attrs = { ...(n.attrs ?? {}), listStyleName: null,
bulletChar: eff.kind === 'bullet' ? eff.bulletChar ?? defaultLevelBullet(depth + 1) : null };
}
for (const c of n.content ?? []) effListKinds(c, own, depth + (list ? 1 : 0));
return n;
};
const held = effListKinds(styled({ ...json, content: foldColumns(json.content ?? []) }));
// Word's list model is flat — an item's further blocks are unnumbered paragraphs at its
// indent, and nesting is the numbering's — so the DOCX leg compares a list's content
// flat, without its structure or the indent Word writes on a block it does not number.
const flatLists = (n) => {
if (!n.content) return n;
const out = [];
let inList = false;
const bare = (b) => (b.attrs ? { ...b, attrs: { ...b.attrs, indent: null } } : b);
for (const kid of n.content.map(flatLists)) {
if (kid.type === 'bulletList' || kid.type === 'orderedList') {
for (const item of kid.content ?? []) for (const b of item.content ?? []) out.push(bare(b));
inList = true;
continue;
}
// A block following the list carries the same indent: it is a continuation of the
// item, which is the one shape Word has for a block it does not number.
const cont = kid.type === 'paragraph' || kid.type === 'heading';
out.push(inList && cont ? bare(kid) : kid);
inList = inList && cont;
}
return { ...n, content: out };
};
const flat = (n) => stripFontHoist(normalize(ext === 'docx' ? flatLists(n) : n));
// The zones the app holds (its own storage is what it exports from) against the ones
// the file gives back — a zone is no part of the document compared above.
const { loadHfDoc, loadExtraHfSections, loadDifferentFirstPage, loadDifferentOddEven, hfIsEmpty, EMPTY_HF_SET } = hfStore;
const heldSets = [{ ...EMPTY_HF_SET,
header: loadHfDoc('header'), footer: loadHfDoc('footer'),
headerFirst: loadHfDoc('header', 'first'), footerFirst: loadHfDoc('footer', 'first'),
headerEven: loadHfDoc('header', 'even'), footerEven: loadHfDoc('footer', 'even'),
differentFirstPage: loadDifferentFirstPage(), differentOddEven: loadDifferentOddEven() }, ...loadExtraHfSections()];
const zoneOf = (d) => (hfIsEmpty(d) ? null : stripFontHoist(normalize(structuredClone(d))));
// Only the variants a set's flags put in play: the file carries them nowhere else.
const zonesOf = (s) => {
const out = { header: zoneOf(s.header), footer: zoneOf(s.footer),
differentFirstPage: !!s.differentFirstPage, differentOddEven: !!s.differentOddEven };
if (out.differentFirstPage) { out.headerFirst = zoneOf(s.headerFirst); out.footerFirst = zoneOf(s.footerFirst); }
if (out.differentOddEven) { out.headerEven = zoneOf(s.headerEven); out.footerEven = zoneOf(s.footerEven); }
return out;
};
const hfDiff = firstDiff(heldSets.map(zonesOf), (res.hfSections ?? []).map(zonesOf));
return { diff: firstDiff(flat(held), flat(effListKinds(styled(res.content)))), hfDiff, warnings: res.warnings,
held: JSON.stringify(held), read: JSON.stringify(res.content),
hfHeld: JSON.stringify(heldSets.map(zonesOf)), hfRead: JSON.stringify((res.hfSections ?? []).map(zonesOf)) };
}, { b64: Buffer.from(bytes).toString('base64'), ext });
try {
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 });
await page.evaluate(() => { window.showSaveFilePicker = undefined; });
const retried = new Set();
for (let run = 0; run < RUNS; run++) {
try {
const seed = SEED + run;
const doc = DOCS[run % DOCS.length];
const r = mulberry32(seed);
const before = await docJson();
await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus', doc));
await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 });
await page.waitForTimeout(1500);
const log = [];
let broken = null;
// Both zones, before the body ops: they can cut the document to a single page,
// and pages 1 to 3 are what the two variant flags spread the six zones over.
await setHfOptions(r);
for (const zone of ['header', 'footer']) {
const pass = await editZone(r, zone);
await writeFile(join(tmpdir(), `monkey-${seed}-${zone}.json`), JSON.stringify(pass.ops));
check(!pass.broken, `seed ${seed}: ${pass.ops.length} ops in the ${zone}${pass.broken ? ` — ${pass.broken}` : ''}`);
if (pass.broken) { broken = pass.broken; break; }
}
if (broken) break;
const flipped = await flipUnderEdit(`zap${seed}`);
check(!flipped, `seed ${seed}: a flag flipped under an open zone${flipped ? ` — ${flipped}` : ''}`);
if (flipped) { broken = flipped; break; }
await watchFlow();
const initial = await docKey();
for (let i = 0; i < OPS && !broken; i++) {
const op = drawOp(r, await docSize());
log.push(op);
if (op.type || op.key) {
const err = await focused();
if (err) broken = `focus threw: ${err}`;
else if (op.type) await page.keyboard.type(op.type);
else await page.keyboard.press(op.key);
} else {
const res = await runCmd(op);
if (res.threw) broken = `${op.cmd} threw: ${res.threw}`;
}
const invalid = await docCheck();
if (invalid) broken = `schema-invalid document: ${invalid}`;
if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`;
}
const final = await docKey();
await writeFile(join(tmpdir(), `monkey-${seed}-ops.json`), JSON.stringify(log));
// Undo until the opened document is back, redo as often, and the edited one is back.
const undone = broken ? -1 : await ed(async (initial) => {
const { firstDiff } = await import('/tests/normalize.ts');
const editor = document.querySelector('.tiptap-host .tiptap').editor;
for (let n = 0; n < 500; n++) {
if (window.__docKey() === initial) return n;
if (!editor.can().undo()) return `undo stops at ${firstDiff({ type: 'doc', content: JSON.parse(initial) }, { type: 'doc', content: JSON.parse(window.__docKey()) })}`;
try { editor.commands.undo(); } catch (e) { return `undo threw: ${String(e).slice(0, 300)}`; }
}
return -1;
}, initial);
const redone = typeof undone !== 'number' || undone < 0 ? null : await ed((n) => {
const editor = document.querySelector('.tiptap-host .tiptap').editor;
for (let i = 0; i < n; i++) editor.commands.redo();
return window.__docKey();
}, undone);
const flowed = await ed(() => window.__flowTx ?? 0);
const history = broken ?? (flowed ? null : typeof undone === 'string' ? undone : undone < 0 ? 'undo does not reach the opened document'
: redone !== final ? `redo does not restore the edited document: ${await ed(async ([a, b]) => (await import('/tests/normalize.ts')).firstDiff({ type: 'doc', content: JSON.parse(a) }, { type: 'doc', content: JSON.parse(b) }), [final, redone])}` : null);
const label = `seed ${seed} on ${doc}: ${log.length} ops, `
+ (flowed ? `the columns flow rewrote the document ${flowed}× — history not checked` : `${undone} undo steps`);
check(!history, history ? `${label} — ${history}` : label);
if (history) for (const op of log.slice(-12)) console.log(' ', JSON.stringify(op));
if (broken) break;
for (const ext of ['odt', 'docx']) {
const bytes = await saveAs(ext);
const schema = hasXmllint ? (ext === 'odt' ? validateOdt : validateDocx)(unzipSync(bytes)) : [];
const rt = await roundTrip(bytes, ext).catch((err) => ({ diff: null, warnings: [], threw: String(err.message ?? err).split('\n')[0] }));
const bad = rt.threw ? `import threw: ${rt.threw}` : schema.length ? `schema: ${schema[0].slice(0, 300)}`
: rt.diff ? `round trip: ${rt.diff}` : rt.hfDiff ? `header/footer: ${rt.hfDiff}`
: rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null;
check(!bad, `seed ${seed}: the saved .${ext} validates and reads back${bad ? ` — ${bad}` : ''}`);
if (bad) {
// The file and both documents, for the repro.
const stem = join(tmpdir(), `monkey-${seed}`);
await writeFile(`${stem}.${ext}`, bytes);
if (rt.held) await Promise.all([writeFile(`${stem}-${ext}-held.json`, rt.held), writeFile(`${stem}-${ext}-read.json`, rt.read)]);
if (rt.hfDiff) await Promise.all([writeFile(`${stem}-${ext}-hf-held.json`, rt.hfHeld), writeFile(`${stem}-${ext}-hf-read.json`, rt.hfRead)]);
console.log(` kept: ${stem}.${ext}`);
}
}
} catch (err) {
// The app reloading under the run (its own recovery prompt, a settings write)
// tears the page context down mid-op — either message says so; one more go, then
// it is a finding.
if (!/Execution context was destroyed|promise was garbage collected/.test(err.message) || retried.has(run)) throw err;
retried.add(run);
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 });
run--;
}
}
} catch (err) {
check(false, `monkey run threw: ${err.stack ?? err}`);
} finally {
if (consoleErrors.length) console.log(`console errors (${consoleErrors.length}): ${[...new Set(consoleErrors)].slice(0, 5).join(' | ')}`);
check(pageErrors.length === 0, pageErrors.length ? `no uncaught page errors — got: ${pageErrors.join(' | ')}` : 'no uncaught page errors');
await browser.close();
if (server) process.kill(-server.pid);
}
process.exit(failures.length ? 1 : 0);