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:
1 parent
0a91e573b4
commit
026f6b6d83
5 files changed
+113
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user