test: a browser run over the pagination and the editing invariants

npm run test:dom opens a corpus document in headless Chromium and holds
its page count against a settle, a reload, a zoom, a page break and its
undo, plus the unsaved dot — the components the vitest suite cannot see.
The smoke run now shares its boot with it (tests/browser.mjs).
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:13 +02:00
1 parent ef8d219cdd
commit c34299ca73
6 files changed
+152 -38

No files matched your search

+2 -1
View File
@@ -17,6 +17,7 @@ npm test # Vitest suite once (tests/**/*.test.ts)
npm run test:watch # Vitest in watch mode
npm run test:lo # LibreOffice legs: round trip + ODT/DOCX render consistency (needs `soffice`)
npm run test:smoke # boots the dist/ build in headless Chromium (tests/smoke/run.mjs)
npm run test:dom # pagination + editing in the real browser (tests/dom/run.mjs)
npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html
npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md)
node scripts/make-thesaurus.mjs # re-vendor public/thesaurus/ from LibreOffice's MyThes data
@@ -30,7 +31,7 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v
documents (by `make-fixtures.mjs` + Word re-saves in `corpus/word/`, never our exporter);
`fuzz-roundtrip.test.ts` round-trips seeded random documents (`fuzzDoc.ts` generator);
`schema-validation.test.ts` validates the `kitchenSink.ts` exports against the vendored
schemas in `tests/schemas/` (self-skips without `xmllint`); `package-lint.test.ts` checks the semantic invariants schemas can't express (unique ids, dangling references); `tests/unit/` holds fast helper tests. All test tooling stays a
schemas in `tests/schemas/` (self-skips without `xmllint`); `package-lint.test.ts` checks the semantic invariants schemas can't express (unique ids, dangling references); `tests/unit/` holds fast helper tests; the two browser runs (`tests/smoke/`, `tests/dom/`) share `tests/browser.mjs`. All test tooling stays a
`devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`.
## Rules
+8
View File
@@ -9,6 +9,14 @@ Then load `npm run dev`, inject a document into `localStorage['edentext-doc']`,
For PDF-export repros specifically: replicate `pdf.ts`'s clone + `html2canvas(...)` inside `page.evaluate` and read the canvas back as a PNG — capturing the real jsPDF `doc.save()` download tends to hang in headless. Inspect output PDFs with poppler-utils (`apt-get install -y poppler-utils`: `pdftoppm`, `pdfimages`, `pdftotext`).
## The two committed browser runs
`npm run test:smoke` and `npm run test:dom` are these probes made permanent: both boot
`dist/` in headless Chromium through `tests/browser.mjs` (preview server, checklist,
`pageerror` collector) and fail on any uncaught page error. The DOM run keeps the
pagination invariants of pass 4 below — one page count through a settle, a reload, a
zoom, a page break and its undo — which is the only test `src/lib/components/**` has.
## Hunting for bugs the suite cannot see
`npm run test:coverage` says where to look: the logic modules are dense with tests,
+1
View File
@@ -19,6 +19,7 @@
"test:watch": "vitest",
"test:lo": "vitest run tests/lo-roundtrip.test.ts tests/lo-render-consistency.test.ts tests/lo-password.test.ts",
"test:smoke": "node tests/smoke/run.mjs",
"test:dom": "node tests/dom/run.mjs",
"test:coverage": "vitest run --coverage",
"test:parity": "node tests/render-parity/run.mjs",
"test:parity:fixtures": "node tests/render-parity/make-fixtures.mjs"
+45
View File
@@ -0,0 +1,45 @@
// What the browser runs (smoke, dom) share: the dist/ preview server, the checklist
// both print, and a page wired to fail the run on an uncaught error.
import { spawn, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const MOD = process.platform === 'darwin' ? 'Meta' : 'Control';
export function checker() {
const failures = [];
const check = (cond, label) => {
console.log(`${cond ? '✓' : '✗'} ${label}`);
if (!cond) failures.push(label);
};
return { check, failures };
}
export async function previewServer(port) {
const up = await fetch(`http://localhost:${port}/`).then(() => true).catch(() => false);
if (up) return null;
if (!existsSync(join(ROOT, 'dist/index.html'))) {
console.log('no dist/, building…');
execSync('npm run build', { cwd: ROOT, stdio: 'inherit' });
}
const proc = spawn('npm', ['run', 'preview', '--', '--port', String(port), '--strictPort'],
{ cwd: ROOT, stdio: 'ignore', detached: true });
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 500));
if (await fetch(`http://localhost:${port}/`).then(() => true).catch(() => false)) return proc;
}
throw new Error('preview server did not start');
}
// Fixed locale, so the UI labels a test clicks are deterministic across machines.
export async function openApp(port) {
const browser = await chromium.launch({ executablePath: chromium.executablePath(), args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' });
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
page.on('dialog', (d) => d.accept());
return { browser, page, pageErrors };
}
+91
View File
@@ -0,0 +1,91 @@
// Browser DOM run: the layout the vitest suite cannot see. A corpus document is
// opened and its pagination watched through a settle, a reload, a zoom and an edit —
// the page count is what every layer on the page is measured against. Fails on any
// uncaught page error.
import { join } from 'node:path';
import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs';
const PORT = +(process.env.DOM_PORT ?? 4185);
const { check, failures } = checker();
const server = await previewServer(PORT);
const { browser, page, pageErrors } = await openApp(PORT);
// "Page 1 of 4" in the status bar is the editor's own count, after its settle loop.
const pageCount = () => page.evaluate(() => {
const m = /of (\d+)/.exec(document.querySelector('.statusbar')?.textContent ?? '');
return m ? +m[1] : 0;
});
const settled = async (want) => {
await page.waitForFunction((n) => {
const m = /of (\d+)/.exec(document.querySelector('.statusbar')?.textContent ?? '');
return m && (n ? +m[1] === n : +m[1] > 1);
}, want, { timeout: 30_000 }).catch(() => {});
return pageCount();
};
try {
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
// A multi-page corpus document, through the file input (no picker in headless).
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/05-breaks.odt'));
await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200,
null, { timeout: 30_000 });
const opened = await settled();
check(opened > 1, `corpus document opens and paginates (${opened} pages)`);
// The settle loop re-measures for a while after the last change; the count it lands
// on has to be the one it keeps.
await page.waitForTimeout(2000);
const still = await pageCount();
check(still === opened, `the page count holds still after the settle (${opened} → ${still})`);
// The same document out of the autosave paginates the same way.
await page.waitForFunction(() => (localStorage.getItem('edentext-doc') ?? '').length > 1000,
null, { timeout: 15_000 });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
const restored = await settled(opened);
check(restored === opened, `the restored document keeps its page count (${restored})`);
// Zoom is a transform over the same layout, so it must not move a page break.
await page.evaluate(() => localStorage.setItem('edentext-zoom', '50'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
const zoomed = await settled(opened);
check(zoomed === opened, `zooming out keeps the page count (${zoomed} at 50%)`);
await page.evaluate(() => localStorage.setItem('edentext-zoom', '100'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
await settled(opened);
// A page break adds exactly one page, and undo takes it back.
await page.click('.tiptap');
await page.keyboard.press(`${MOD}+End`);
await page.keyboard.press(`${MOD}+Enter`);
const broken = await settled(opened + 1);
await page.keyboard.press(`${MOD}+z`);
const undone = await settled(opened);
check(broken === opened + 1 && undone === opened,
`a page break adds a page and undo takes it back (${opened} → ${broken} → ${undone})`);
// The unsaved dot: an edit raises it, opening a file clears it again.
await page.keyboard.type('nachtrag');
await page.waitForTimeout(500);
const marked = await page.evaluate(() => !!document.querySelector('.doc-dirty'));
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt'));
await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(),
null, { timeout: 30_000 });
await page.waitForTimeout(800);
const cleared = await page.evaluate(() => !!document.querySelector('.doc-dirty'));
check(marked && !cleared, `the unsaved dot follows the edits (edit: ${marked}, after open: ${cleared})`);
} catch (err) {
check(false, `dom run threw: ${err.message ?? 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);
+5 -37
View File
@@ -2,45 +2,13 @@
// paths jsdom cannot — the bundle boots, typing works, autosave survives a reload,
// a corpus document imports and paginates, a margin balloon holds its content. Fails on
// any uncaught page error.
import { spawn, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
import { join } from 'node:path';
import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const PORT = +(process.env.SMOKE_PORT ?? 4180);
const MOD = process.platform === 'darwin' ? 'Meta' : 'Control';
const failures = [];
const check = (cond, label) => {
console.log(`${cond ? '✓' : '✗'} ${label}`);
if (!cond) failures.push(label);
};
async function previewServer() {
const up = await fetch(`http://localhost:${PORT}/`).then(() => true).catch(() => false);
if (up) return null;
if (!existsSync(join(ROOT, 'dist/index.html'))) {
console.log('no dist/, building…');
execSync('npm run build', { cwd: ROOT, stdio: 'inherit' });
}
const proc = spawn('npm', ['run', 'preview', '--', '--port', String(PORT), '--strictPort'],
{ cwd: ROOT, stdio: 'ignore', detached: true });
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 500));
if (await fetch(`http://localhost:${PORT}/`).then(() => true).catch(() => false)) return proc;
}
throw new Error('preview server did not start');
}
const server = await previewServer();
const browser = await chromium.launch({ executablePath: chromium.executablePath(), args: ['--no-sandbox'] });
// Fixed locale, so the UI labels the test clicks are deterministic across machines.
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' });
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
page.on('dialog', (d) => d.accept());
const { check, failures } = checker();
const server = await previewServer(PORT);
const { browser, page, pageErrors } = await openApp(PORT);
try {
// Boot on a clean profile.