From 026f6b6d8333f4d87ed5296a778b97763d930a0a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 21:58:47 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 15 ++++++++ CLAUDE.md | 1 + package-lock.json | 14 +++++++ package.json | 2 + tests/smoke/run.mjs | 81 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+) create mode 100644 tests/smoke/run.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11d471..09ca765 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 070ed7b..8428e60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` diff --git a/package-lock.json b/package-lock.json index 6e7fa4e..072eeb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 66fa2f8..d38f817 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/smoke/run.mjs b/tests/smoke/run.mjs new file mode 100644 index 0000000..76a034d --- /dev/null +++ b/tests/smoke/run.mjs @@ -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);