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 | undefined; let browser: Awaited> | 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((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 }); }