lo-fuzz.test.ts hands every fuzz seed to LibreOffice as .odt and .docx and compares what comes back, listing the differences LibreOffice makes by design. test:layout opens the corpus, the showcase and fuzz seeds in the browser, holds the page count against LibreOffice and checks the lines for overlap, margin escapes and stranded headings. test:monkey types at random on corpus documents and asserts the schema, undo to the start, redo, and that both saved formats read back. browser.mjs now carries the dev server and the settle helper.
203 lines
12 KiB
JavaScript
203 lines
12 KiB
JavaScript
// Layout run: the documents the repo carries — corpus, showcase and fuzz seeds — opened in
|
||
// the editor, the page count held against LibreOffice's PDF, and the rendered lines checked
|
||
// for what no layout may show: overlapping lines, text off the page or past the margins, a
|
||
// heading alone at the foot of a page or column. `[regex]` limits the run by file name.
|
||
import { execFileSync, execSync } from 'node:child_process';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, existsSync, copyFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, basename, extname, relative } from 'node:path';
|
||
import { ROOT, checker, devServer, openApp, settle } from '../browser.mjs';
|
||
|
||
const PORT = +(process.env.LAYOUT_PORT ?? 4188);
|
||
const SEEDS = +(process.env.LAYOUT_SEEDS ?? 10);
|
||
const only = process.argv[2] ? new RegExp(process.argv[2]) : null;
|
||
const has = (tool) => { try { execSync(`command -v ${tool}`, { stdio: 'ignore' }); return true; } catch { return false; } };
|
||
const LO = has('soffice') && has('pdfinfo');
|
||
const { check, failures } = checker();
|
||
|
||
const files = (dir) => (existsSync(dir)
|
||
? readdirSync(dir).filter((f) => /\.(odt|docx)$/.test(f) && !f.startsWith('~$')).sort().map((f) => join(dir, f))
|
||
: []);
|
||
const docs = [...files(join(ROOT, 'tests/corpus')), ...files(join(ROOT, 'tests/corpus/word')), ...files(join(ROOT, 'docs/showcase'))];
|
||
const work = mkdtempSync(join(tmpdir(), 'layout-'));
|
||
const server = await devServer(PORT);
|
||
const { browser, page, pageErrors } = await openApp(PORT);
|
||
if (!LO) console.log('soffice/pdfinfo missing: page counts are not held against LibreOffice');
|
||
|
||
// LibreOffice's page count from its PDF, the blank pages it inserts itself kept as the
|
||
// editor keeps them; one soffice call for the whole batch, under unique names.
|
||
function loPages(paths) {
|
||
const inDir = join(work, 'in'), out = join(work, 'pdf');
|
||
mkdirSync(inDir, { recursive: true }); mkdirSync(out, { recursive: true });
|
||
const named = paths.map((p, i) => { const f = join(inDir, `${i}-${basename(p)}`); copyFileSync(p, f); return f; });
|
||
execFileSync('soffice', ['--headless', '--norestore', `-env:UserInstallation=file://${work}/profile`, '--convert-to',
|
||
'pdf:writer_pdf_Export:{"IsSkipEmptyPages":{"type":"boolean","value":"false"}}', '--outdir', out, ...named],
|
||
{ stdio: 'pipe', timeout: 900_000 });
|
||
const pages = new Map();
|
||
named.forEach((f, i) => {
|
||
const pdf = join(out, basename(f, extname(f)) + '.pdf');
|
||
const m = existsSync(pdf) && /Pages:\s+(\d+)/.exec(execFileSync('pdfinfo', [pdf], { encoding: 'utf8' }));
|
||
if (m) pages.set(paths[i], +m[1]);
|
||
});
|
||
return pages;
|
||
}
|
||
|
||
// Runs in the browser: the rendered lines against the page sheets and the document's
|
||
// margins (single-section documents only: a later section may have margins of its own).
|
||
function lintLayout() {
|
||
const paper = document.querySelector('.paper');
|
||
const origin = paper.getBoundingClientRect();
|
||
const rel = (r) => ({ top: r.top - origin.top, bottom: r.bottom - origin.top, left: r.left - origin.left, right: r.right - origin.left });
|
||
const sheets = [...document.querySelectorAll('.page-sheet')].map((s) => rel(s.getBoundingClientRect()));
|
||
const pageOf = (y) => sheets.findIndex((s) => y >= s.top - 1 && y <= s.bottom + 1);
|
||
const cs = getComputedStyle(document.documentElement);
|
||
const px = (v) => parseFloat(cs.getPropertyValue(v));
|
||
const margins = { top: px('--user-margin-top'), bottom: px('--user-margin-bottom'), left: px('--user-margin-left'), right: px('--user-margin-right') };
|
||
const oneSection = !cs.getPropertyValue('--pb-section-page').includes(',');
|
||
const mirrored = /[1-9]/.test(cs.getPropertyValue('--pb-section-mirror'));
|
||
const side = mirrored ? Math.min(margins.left, margins.right) : null;
|
||
|
||
// Where a text node is painted: a frame may sit on the text by design, a formula
|
||
// stacks its own boxes, and a hidden or spacer node is not painted at all.
|
||
const originOf = (node) => {
|
||
for (let e = node.parentElement; e && e !== paper; e = e.parentElement) {
|
||
if (e.hasAttribute('data-page-break-spacer') || e.classList.contains('band-layer')) return null;
|
||
if (e.classList.contains('page-decor-layer') || e.classList.contains('line-number-layer')) return null;
|
||
if (e.classList.contains('hf-layer')) return 'hf';
|
||
if (e.classList.contains('textbox-node') || e.classList.contains('image-node')) return 'frame';
|
||
if (e.classList.contains('formula')) return 'formula';
|
||
if (e.classList.contains('note')) return 'note';
|
||
const s = getComputedStyle(e);
|
||
if (s.display === 'none' || s.visibility === 'hidden') return null;
|
||
}
|
||
return 'body';
|
||
};
|
||
const lines = [];
|
||
const walker = document.createTreeWalker(paper, NodeFilter.SHOW_TEXT);
|
||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||
if (!n.nodeValue.trim()) continue;
|
||
const kind = originOf(n);
|
||
if (!kind) continue;
|
||
// Between the tabs: a tab's own box runs to its stop, past a cell's edge if need be.
|
||
for (const m of n.nodeValue.matchAll(/[^\t]+/g)) {
|
||
const range = document.createRange();
|
||
range.setStart(n, m.index); range.setEnd(n, m.index + m[0].length);
|
||
for (const r of range.getClientRects()) {
|
||
if (r.width < 0.5 || r.height < 0.5) continue;
|
||
const b = rel(r);
|
||
lines.push({ ...b, kind, page: pageOf((b.top + b.bottom) / 2), text: m[0].trim().slice(0, 40) });
|
||
}
|
||
}
|
||
}
|
||
const issues = [];
|
||
const box = (b) => [b.left, b.top, b.right, b.bottom].map(Math.round).join(',');
|
||
const issue = (kind, l, extra = {}) => { if (issues.length < 60) issues.push({ kind, page: l.page + 1, text: l.text, at: l.left != null ? box(l) : undefined, ...extra }); };
|
||
|
||
// Two painted lines on one page may not share their box (a frame may, a formula's
|
||
// pieces may). ponytail: a sorted sweep, quadratic only across lines that share a band.
|
||
lines.sort((a, b) => a.top - b.top);
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const a = lines[i];
|
||
if (a.kind === 'frame' || a.kind === 'formula') continue;
|
||
for (let j = i + 1; j < lines.length && lines[j].top < a.bottom; j++) {
|
||
const b = lines[j];
|
||
if (b.kind === 'frame' || b.kind === 'formula' || b.page !== a.page) continue;
|
||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||
if (dy > 0.5 * Math.min(a.bottom - a.top, b.bottom - b.top) && dx > 2) issue('overlap', a, { other: b.text, otherKind: b.kind, otherAt: box(b) });
|
||
}
|
||
}
|
||
// Body text stays on a sheet and, in a single-section document, inside the margins.
|
||
for (const l of lines) {
|
||
if (l.kind !== 'body' && l.kind !== 'note') continue;
|
||
if (l.page < 0) { issue('in the page gap', l); continue; }
|
||
const s = sheets[l.page];
|
||
if (l.left < s.left - 1 || l.right > s.right + 1) issue('off the page', l, { sheet: box(s) });
|
||
if (!oneSection) continue;
|
||
const [ml, mr] = side == null ? [margins.left, margins.right] : [side, side];
|
||
const body = { left: s.left + ml, top: s.top + margins.top, right: s.right - mr, bottom: s.bottom - margins.bottom };
|
||
if (l.top < body.top - 3) issue('above the top margin', l, { body: box(body) });
|
||
if (l.bottom > body.bottom + 3) issue('below the bottom margin', l, { body: box(body) });
|
||
// ponytail: 6px of slack for a justified line's overhang and the sheet's own border.
|
||
if (l.left < body.left - 6 || l.right > body.right + 6) issue('beyond the side margin', l, { body: box(body) });
|
||
}
|
||
// A heading stays with the block it introduces, across a page and a column alike,
|
||
// unless that block asks for the break itself.
|
||
const blocks = (parent) => [...parent.children].filter((e) => !e.hasAttribute('data-page-break-spacer'));
|
||
for (const h of paper.querySelectorAll('.tiptap :is(h1, h2, h3, h4, h5, h6)')) {
|
||
if (h.closest('td, th, li, .textbox-node, .hf-layer')) continue;
|
||
const sib = blocks(h.parentElement);
|
||
const next = sib[sib.indexOf(h) + 1];
|
||
if (!next || next.classList.contains('note') || next.hasAttribute('data-page-break-before')) continue;
|
||
const hr = rel(h.getBoundingClientRect()), nr = rel(next.getBoundingClientRect());
|
||
if (hr.bottom - hr.top < 1 || nr.bottom - nr.top < 1) continue;
|
||
const l = { page: pageOf(hr.bottom - 1), text: h.textContent.trim().slice(0, 40) };
|
||
if (pageOf(nr.top + 1) !== l.page) issue('heading alone at the page end', l);
|
||
else if (h.closest('.columns-node') && nr.left > hr.left + 5) issue('heading alone at the column end', l);
|
||
}
|
||
const tiptap = document.querySelector('.tiptap');
|
||
if (tiptap.scrollWidth > tiptap.clientWidth + 1) issues.push({ kind: 'content wider than the page', page: 0, text: `${tiptap.scrollWidth}px in ${tiptap.clientWidth}px` });
|
||
return { pages: sheets.length, lines: lines.length, issues };
|
||
}
|
||
|
||
const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode(...u8.subarray(i, i + 0x8000)); return btoa(s); };
|
||
|
||
try {
|
||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
|
||
await page.waitForSelector('.tiptap', { timeout: 30_000 });
|
||
// The fuzz seeds, written by the app's own exporter in the page.
|
||
mkdirSync(join(work, 'fuzz'), { recursive: true });
|
||
for (let seed = 1; seed <= SEEDS; seed++) {
|
||
const odt = await page.evaluate(async ({ seed, b64 }) => {
|
||
const [{ buildOdt }, { mulberry32 }, { genCase, exportArgs }] = await Promise.all([
|
||
import('/src/lib/export/odt.ts'), import('/tests/fuzzDoc.ts'), import('/tests/fuzzOptions.ts')]);
|
||
const { doc, opts } = genCase(mulberry32(seed));
|
||
return new Function('return ' + b64)()(await buildOdt(doc, ...exportArgs(opts)));
|
||
}, { seed, b64: b64.toString() });
|
||
const f = join(work, 'fuzz', `seed-${seed}.odt`);
|
||
writeFileSync(f, Buffer.from(odt, 'base64'));
|
||
docs.push(f);
|
||
}
|
||
const todo = docs.filter((d) => !only || only.test(d));
|
||
const retried = new Set();
|
||
const lo = LO ? loPages(todo) : new Map();
|
||
|
||
for (const file of todo) {
|
||
const name = file.startsWith(ROOT) ? relative(ROOT, file) : `fuzz/${basename(file)}`;
|
||
try {
|
||
// A fresh editor for each document, so its settle cannot be the previous one's.
|
||
await page.evaluate(() => localStorage.clear());
|
||
await page.reload({ waitUntil: 'load' });
|
||
await page.waitForSelector('.tiptap', { timeout: 30_000 });
|
||
await page.setInputFiles('input.file-input', file);
|
||
// The document's name says the file is in; a document may hold nothing to wait for.
|
||
await page.waitForFunction((n) => document.querySelector('.doc-name-input')?.value.startsWith(n), basename(file, extname(file)), { timeout: 60_000 });
|
||
await settle(page, true);
|
||
const r = await page.evaluate(lintLayout);
|
||
const ref = lo.get(file);
|
||
const off = ref == null ? 0 : r.pages - ref;
|
||
// A page or a tenth either way is the slack two layout engines take.
|
||
const pagesOk = Math.abs(off) <= Math.max(1, Math.round((ref ?? 0) / 10));
|
||
const label = `${name} editor ${r.pages}p${ref != null ? ` / LO ${ref}p` : ''}${off && pagesOk ? ' (~)' : ''} ${r.issues.length} issue(s)`;
|
||
check(pagesOk && !r.issues.length, label);
|
||
for (const i of r.issues.slice(0, 8)) {
|
||
const where = i.other ? ` × ${i.otherKind} "${i.other}" @${i.otherAt}` : i.body ? ` (body ${i.body})` : i.sheet ? ` (sheet ${i.sheet})` : '';
|
||
console.log(` p${i.page} ${i.kind}: "${i.text}"${i.at ? ` @${i.at}` : ''}${where}`);
|
||
}
|
||
if (r.issues.length > 8) console.log(` … ${r.issues.length - 8} more`);
|
||
} catch (err) {
|
||
// A layout that never settles is a finding of its own, not the end of the run; a
|
||
// page torn down under the probe (the app's own reload) gets one more go.
|
||
if (/Execution context was destroyed/.test(err.message) && !retried.has(file)) { retried.add(file); todo.push(file); continue; }
|
||
check(false, `${name}: ${err.message.split('\n')[0]}`);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
check(false, `layout run threw: ${err.stack ?? err}`);
|
||
} finally {
|
||
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);
|