- @vite-pwa/sveltekit: workbox precache (incl. pdfjs worker), generated manifest, prompt-style updates via PwaUpdatePrompt toast - prerendered routes for offline navigation, PWA icons, theme-color + apple-touch-icon - pwa offline/update e2e suite (pnpm test:pwa, playwright.pwa.config.ts) - datenschutz: offline/install section; prettier cleanup of shadcn components
297 lines
9.3 KiB
TypeScript
297 lines
9.3 KiB
TypeScript
/// <reference types="node" />
|
|
|
|
// 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<void>((resolve) => setTimeout(resolve, ms));
|
|
|
|
async function stopServer() {
|
|
const running = server;
|
|
server = undefined;
|
|
if (!running) return;
|
|
await new Promise<void>((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,
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><desc>probe</desc></svg>'
|
|
);
|
|
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();
|
|
});
|