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:
1 parent
ef8d219cdd
commit
c34299ca73
6 files changed
+152
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user