Files
minabot/scripts/pwa-smoke.ts
syntaxbullet ccd998ab04 Add service worker implementation and tests for PWA caching strategy
- Implemented a service worker (sw.js) that handles installation, activation, and fetch events.
- Added caching logic for public assets and a fallback for offline navigation.
- Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic.
- Ensured that private API routes and third-party requests are excluded from caching.
2026-09-08 17:19:12 +02:00

195 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { chromium } from "playwright";
import { Database } from "bun:sqlite";
import { hashToken } from "../src/auth";
// Exercise a copied production bundle and disposable account. No live data or Discord calls.
const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-"));
const project = process.cwd();
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
const port = probe.port!;
probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const databasePath = path.join(directory, "test.sqlite");
const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin,
DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "",
DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" };
let server: ReturnType<typeof Bun.spawn> | undefined;
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
const screenshots = path.resolve(".cache/pwa");
try {
await cp("dist", path.join(directory, "dist"), { recursive: true });
await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir");
const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" });
assert.equal(await migration.exited, 0, "Production migrations failed");
const sqlite = new Database(databasePath);
const now = Date.now();
const token = "p".repeat(43);
sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now);
sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000);
sqlite.close();
server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" });
let healthy = false;
for (let attempt = 0; attempt < 100; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ }
if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text());
await Bun.sleep(50);
}
assert(healthy, "Production server failed to start");
const swResponse = await fetch(`${origin}/sw.js`);
assert.equal(swResponse.headers.get("service-worker-allowed"), "/");
assert.equal(swResponse.headers.get("cache-control"), "no-cache");
assert.match(swResponse.headers.get("content-type")!, /javascript/);
assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405);
assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store");
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
const pageErrors: string[] = [];
page.on("pageerror", error => pageErrors.push(error.message));
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update");
const cdp = await context.newCDPSession(page);
const manifestResult = await cdp.send("Page.getAppManifest");
assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors));
const manifest = JSON.parse(manifestResult.data!);
assert.equal(manifest.id, "/");
assert.equal(manifest.display, "standalone");
assert.equal(manifest.scope, "/");
for (const icon of manifest.icons) {
const response = await fetch(new URL(icon.src, origin));
assert.match(response.headers.get("content-type")!, /image\/png/);
const png = Buffer.from(await response.arrayBuffer());
assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes);
}
const installability = await cdp.send("Page.getInstallabilityErrors");
assert.deepEqual(installability.installabilityErrors, []);
console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy");
await mkdir(screenshots, { recursive: true });
await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true });
await page.getByRole("radio", { name: "Dark", exact: true }).check();
assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b");
await page.getByRole("radio", { name: "Light", exact: true }).check();
await context.setOffline(true);
await page.goto(`${origin}/a/deep/link`);
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible());
await page.setViewportSize({ width: 375, height: 812 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true });
await page.goto(`${origin}/design-system`);
await page.getByRole("heading", { name: /design system/i }).first().waitFor();
await context.setOffline(false);
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true });
console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection");
// Keep an unsaved draft in another open window while applying an update here.
const other = await context.newPage();
await other.goto(origin);
const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true });
await draft.fill("6");
const workerPath = path.join(directory, "dist/pwa/sw.js");
const original = await Bun.file(workerPath).text();
const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!;
const config = JSON.parse(JSON.parse(configMatch[1]!));
// A broken release must leave the active worker and existing offline assets usable.
const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] };
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`));
await page.evaluate(async () => {
const registration = (await navigator.serviceWorker.getRegistration())!;
await new Promise<void>((resolve, reject) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing!;
worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); });
}, { once: true });
void registration.update().catch(reject);
});
});
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0);
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("button", { name: "Try again", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
config.version += "-qa";
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`));
await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); });
await page.getByRole("button", { name: "Update app", exact: true }).waitFor();
await page.getByRole("button", { name: "Update app", exact: true }).click();
await page.getByRole("button", { name: "Later", exact: true }).click();
assert.equal(await draft.inputValue(), "6");
await page.getByRole("button", { name: "Update app", exact: true }).click();
await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft");
await other.getByRole("button", { name: "Update app", exact: true }).waitFor();
const keys = await page.evaluate(() => caches.keys());
assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained");
console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft");
await context.addCookies([{ name: "minabot_session", value: token, url: origin }]);
await page.reload();
await page.getByRole("button", { name: "Sign out", exact: true }).waitFor();
const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status }));
assert.equal(privateResponse.status, 200);
await context.setOffline(true);
assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached");
await context.setOffline(false);
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
const cacheUrls = await page.evaluate(async () => {
const urls: string[] = [];
for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname);
return urls;
});
assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage");
assert.deepEqual(pageErrors, []);
console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors");
const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
await manualContext.addInitScript(() => {
window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); });
});
const manual = await manualContext.newPage();
await manual.goto(origin);
await manual.getByRole("button", { name: "Install app", exact: true }).click();
await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor();
assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible());
assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows");
await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true });
await manual.getByRole("button", { name: "Got it", exact: true }).click();
await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0);
await manualContext.close();
console.log("PASS mobile installation guidance and installed-state controls");
console.log(`Screenshots: ${screenshots}`);
} catch (error) {
const page = browser?.contexts()[0]?.pages()[0];
if (page) {
console.error("Browser at failure:", await page.locator("body").innerText());
await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true });
}
throw error;
} finally {
await browser?.close();
if (server) { server.kill(); await server.exited; }
await rm(directory, { recursive: true, force: true });
}