From c34299ca737e6a6cdf115b362da9be916890516b Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 4 Sep 2026 00:18:15 +0200 Subject: [PATCH] test: a browser run over the pagination and the editing invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CLAUDE.md | 3 +- docs/headless-testing.md | 8 ++++ package.json | 1 + tests/browser.mjs | 45 ++++++++++++++++++++ tests/dom/run.mjs | 91 ++++++++++++++++++++++++++++++++++++++++ tests/smoke/run.mjs | 42 +++---------------- 6 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 tests/browser.mjs create mode 100644 tests/dom/run.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 9e6c6a7..9a64c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/headless-testing.md b/docs/headless-testing.md index 0ace0cd..5e5e5aa 100644 --- a/docs/headless-testing.md +++ b/docs/headless-testing.md @@ -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, diff --git a/package.json b/package.json index 19d6120..8cdbd75 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/browser.mjs b/tests/browser.mjs new file mode 100644 index 0000000..a7b6125 --- /dev/null +++ b/tests/browser.mjs @@ -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 }; +} diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs new file mode 100644 index 0000000..95adbfd --- /dev/null +++ b/tests/dom/run.mjs @@ -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); diff --git a/tests/smoke/run.mjs b/tests/smoke/run.mjs index aad3168..2e7b52a 100644 --- a/tests/smoke/run.mjs +++ b/tests/smoke/run.mjs @@ -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.