/// // TEMPORARY verification harness for the PWA (not part of the committed suite). // Run against a production build: // pnpm build && pnpm exec playwright test -c playwright.pwa.config.ts // // Note: a "deployment" has to restart the node server, because adapter-node's static // handler (sirv, non-dev mode) stats the build directory once at startup and caches the // ETags forever. import { expect, test, type Page } from '@playwright/test'; import { spawn, execSync, type ChildProcess } from 'node:child_process'; import { readdirSync, rmSync, writeFileSync } from 'node:fs'; import { createPdf, createZip, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors, waitForLoadedWorkspace, zipDropzone } from './helpers'; const PORT = 4179; const BASE = `http://127.0.0.1:${PORT}`; // unique per test-file run: leftovers from an aborted run stay inert in old precache versions const PROBE = `static/pwa-update-probe-${process.pid}.svg`; const PROBE_URL = `/${PROBE.split('/').pop()}`; let server: ChildProcess | undefined; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function stopServer() { const running = server; server = undefined; if (!running) return; await new Promise((resolve) => { if (running.exitCode !== null) return resolve(); running.once('exit', () => resolve()); running.kill(); }); // give the listener time to release the port await wait(250); } async function startServer() { await stopServer(); const child = spawn('node', ['build/index.js'], { env: { ...process.env, PORT: String(PORT), ORIGIN: BASE } }); server = child; if (child.stderr) { child.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk)); } for (let attempt = 0; attempt < 200; attempt += 1) { try { const response = await fetch(`${BASE}/`); if (response.ok) return; } catch { /* not listening yet */ } await wait(200); } throw new Error('node server did not start'); } async function rebuild(label: string) { console.log(`building (${label})…`); try { execSync('pnpm build', { stdio: 'ignore' }); } catch (error) { throw new Error(`build failed for ${label}: ${String(error)}`); } } const swReady = (page: Page) => page.evaluate(() => navigator.serviceWorker.ready.then((r) => r.active?.scriptURL ?? null)); const precachedPaths = (page: Page) => page.evaluate(async () => { const names = await caches.keys(); const precache = names.find((name) => name.includes('precache')); if (!precache) return null; const cache = await caches.open(precache); return (await cache.keys()).map((request) => new URL(request.url).pathname).sort(); }); const waitForPrecache = (page: Page, minimum = 50) => page.waitForFunction( (min) => caches.keys().then(async (names) => { const precache = names.find((name) => name.includes('precache')); if (!precache) return false; const cache = await caches.open(precache); return (await cache.keys()).length >= min; }), minimum ); const removeProbeFiles = () => { for (const file of readdirSync('static')) { if (file.startsWith('pwa-update-probe-')) rmSync(`static/${file}`, { force: true }); } }; test.beforeEach(async () => { rmSync(PROBE, { force: true }); await startServer(); }); test.afterAll(async () => { await stopServer(); const hadLeftovers = readdirSync('static').some((file) => file.startsWith('pwa-update-probe-')); removeProbeFiles(); // purge leftover probes from an aborted run out of the precache if (hadLeftovers) await rebuild('afterAll'); }); test('production build serves the manifest, icons and service worker', async ({ page, request }) => { const manifestResponse = await request.get('/manifest.webmanifest'); expect(manifestResponse.status()).toBe(200); expect(manifestResponse.headers()['content-type']).toContain('application/manifest+json'); const manifest = await manifestResponse.json(); expect(manifest.name).toBe('beA-Edit'); expect(manifest.start_url).toBe('/'); expect(manifest.display).toBe('standalone'); const iconPaths = [ '/icons/icon-192.png', '/icons/icon-512.png', '/icons/icon-maskable-512.png', '/icons/apple-touch-icon.png' ]; for (const path of iconPaths) { const response = await request.get(path); expect(response.status(), path).toBe(200); } const swResponse = await request.get('/sw.js'); expect(swResponse.status()).toBe(200); // the service worker script must never be cached aggressively by the origin expect(swResponse.headers()['cache-control'] ?? '').not.toMatch(/max-age=[1-9]/); await page.goto(BASE); await expect(swReady(page)).resolves.toBe(`${BASE}/sw.js`); await expect(page.locator('link[rel="manifest"]')).toHaveAttribute( 'href', '/manifest.webmanifest' ); await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#122f62'); }); test('all documents, assets and the pdfjs worker are precached', async ({ page }) => { await page.goto(BASE); await swReady(page); await waitForPrecache(page); const paths = await precachedPaths(page); expect(paths).not.toBeNull(); for (const route of [ '/', '/tools/bea', '/tools/merge', '/tools/metadata', '/impressum', '/datenschutz', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/icons/icon-maskable-512.png', '/logo.svg' ]) { expect(paths, route).toContain(route); } // pdfjs worker chunk: required to render PDF thumbnails offline expect(paths!.some((path) => path.endsWith('.mjs'))).toBe(true); // the unused font drop from static/ must not bloat the precache expect(paths!.some((path) => path.startsWith('/Nunito/'))).toBe(false); }); test('offline: documents, ZIP processing and PDF merge work without a network', async ({ page, context }) => { const pageErrors = trackPageErrors(page); await page.goto(BASE); await swReady(page); await waitForPrecache(page); await context.setOffline(true); // direct document navigation is answered by the service worker await page.goto(`${BASE}/tools/bea`); await expect(page.getByRole('heading', { name: 'beA-Edit' })).toBeVisible(); // the whole processing chain runs offline, including pdfjs thumbnails const zip = await createZip('offline-archiv', 2); await dropFiles(page, zipDropzone(page), [ { name: 'offline-archiv.zip', mimeType: 'application/zip', buffer: zip } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/, { timeout: 45_000 }); // a second tool route, still offline await page.goto(`${BASE}/tools/merge`); await expect(page.getByRole('button', { name: 'PDF-Dateien hinzufügen' })).toBeVisible(); await dropFiles(page, pdfDropzone(page), [ { name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Offline-Eins') }, { name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Offline-Zwei') } ]); await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'PDFs zusammenfügen' }).click(); await expectPdfDownload(await downloadPromise, /^zusammengefuegt\.pdf$/); // legal routes and the start page reload await page.goto(`${BASE}/datenschutz`); await expect(page.locator('h1, h2').first()).toBeVisible(); await page.goto(BASE); await expect(page.getByRole('banner')).toBeVisible(); expect(pageErrors).toEqual([]); }); test('update: toast defers without interrupting, reload activates the new version', async ({ page }) => { await page.goto(BASE); await swReady(page); await waitForPrecache(page); expect(await precachedPaths(page)).not.toContain(PROBE_URL); // deploy: new build + new server process writeFileSync( PROBE, 'probe' ); await rebuild('with probe'); await startServer(); const toast = page.getByText('Neue Version von beA-Edit verfügbar.'); await page.reload(); await expect(toast).toBeVisible({ timeout: 30_000 }); // "Später" must not reload the page: in-progress state survives await page.evaluate(() => { (window as unknown as { __pwaMarker?: string }).__pwaMarker = 'intakt'; }); await page.getByRole('button', { name: 'Später' }).click(); await expect(toast).toBeHidden(); expect( await page.evaluate(() => (window as unknown as { __pwaMarker?: string }).__pwaMarker) ).toBe('intakt'); // the offer returns on the next visit await page.reload(); await expect(toast).toBeVisible({ timeout: 30_000 }); // "Neu laden" activates the waiting worker and reloads the page await page.evaluate(() => { (window as unknown as { __pwaMarker?: string }).__pwaMarker = 'verloren'; }); await page.getByRole('button', { name: 'Neu laden' }).click(); await expect .poll( async () => { try { return await page.evaluate( () => (window as unknown as { __pwaMarker?: string }).__pwaMarker ); } catch { // the activation reload destroys the execution context: exactly the expected outcome return undefined; } }, { timeout: 30_000 } ) .toBeUndefined(); await page.waitForLoadState('load'); await expect(swReady(page)).resolves.toBe(`${BASE}/sw.js`); await waitForPrecache(page, 50); await expect .poll(async () => (await precachedPaths(page))?.includes(PROBE_URL), { timeout: 30_000 }) .toBe(true); rmSync(PROBE, { force: true }); await rebuild('cleanup'); await startServer(); });