pwa / offline mode
- @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
This commit is contained in:
1 parent
b7969b4c9e
commit
f09384d2e6
37 files changed
+4130
-273
No files matched your search
@@ -0,0 +1,296 @@
|
||||
/// <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();
|
||||
});
|
||||
Reference in new issue
Block a user