///
// 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,
''
);
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();
});