test: headless-Chromium smoke test for the UI layer

tests/smoke/run.mjs boots the production build in Playwright Chromium and
checks what jsdom cannot: the bundle boots, typing and the bold shortcut
reach ProseMirror, autosave survives a reload, a corpus ODT imports and
paginates; any uncaught page error fails the run. Wired as npm run
test:smoke and a CI job (build + chromium install), playwright-core is
now a devDependency.
This commit is contained in:
Steffen Becker committed 2026-08-15 21:58:47 +02:00
1 parent 0a91e573b4
commit 026f6b6d83
5 files changed
+113

No files matched your search

+15
View File
@@ -24,3 +24,18 @@ jobs:
- run: npm run check
# LibreOffice is not installed here, so the lo-roundtrip suite self-skips.
- run: npm test
# Production build booted in headless Chromium — catches bundling and boot
# regressions the jsdom suite cannot see.
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright-core install chromium
- run: npm run build
- run: npm run test:smoke
+1
View File
@@ -16,6 +16,7 @@ npm run check # svelte-check type-check (svelte + ts)
npm test # Vitest suite once (tests/**/*.test.ts)
npm run test:watch # Vitest in watch mode
npm run test:lo # LibreOffice round-trip leg only (needs `soffice` on PATH)
npm run test:smoke # boots the dist/ build in headless Chromium (tests/smoke/run.mjs)
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
```
+14
View File
@@ -46,6 +46,7 @@
"dictionary-de": "^3.0.0",
"dictionary-en": "^4.0.0",
"jsdom": "^29.1.1",
"playwright-core": "^1.62.1",
"svelte": "^5.55.9",
"svelte-check": "^4.7.1",
"typescript": "^6.0.3",
@@ -2309,6 +2310,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+2
View File
@@ -18,6 +18,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:lo": "vitest run tests/lo-roundtrip.test.ts",
"test:smoke": "node tests/smoke/run.mjs",
"test:parity": "node tests/render-parity/run.mjs",
"test:parity:fixtures": "node tests/render-parity/make-fixtures.mjs"
},
@@ -27,6 +28,7 @@
"dictionary-de": "^3.0.0",
"dictionary-en": "^4.0.0",
"jsdom": "^29.1.1",
"playwright-core": "^1.62.1",
"svelte": "^5.55.9",
"svelte-check": "^4.7.1",
"typescript": "^6.0.3",
+81
View File
@@ -0,0 +1,81 @@
// Browser smoke test: boots the production build in headless Chromium and checks the
// paths jsdom cannot — the bundle boots, typing works, autosave survives a reload,
// a corpus document imports and paginates. 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';
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'] });
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
page.on('dialog', (d) => d.accept());
try {
// Boot on a clean profile.
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 });
check(true, 'app boots, editor mounts');
// Typing and a keyboard command reach ProseMirror.
await page.click('.tiptap');
await page.keyboard.type('Hello smoke ');
await page.keyboard.press(`${MOD}+b`);
await page.keyboard.type('bold');
const strong = await page.evaluate(() => document.querySelector('.tiptap strong')?.textContent);
check(strong === 'bold', 'typing + bold shortcut render');
// Autosave (1s debounce) persists the document across a reload.
await page.waitForFunction(() => (localStorage.getItem('edentext-doc') ?? '').includes('Hello smoke'),
null, { timeout: 10_000 });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
await page.waitForFunction(() => document.querySelector('.tiptap')?.textContent.includes('Hello smoke'),
null, { timeout: 10_000 });
check(true, 'autosave survives a reload');
// A corpus document imports, renders its table and reports a page count.
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 });
const pages = await page.evaluate(() => document.querySelector('.statusbar')?.textContent ?? '');
check(/\d/.test(pages), 'ODT import renders table + statusbar page count');
} catch (err) {
check(false, `smoke 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);