import assert from "node:assert/strict"; import { chromium } from "playwright"; const BASE = process.env.PWA_BASE_URL || "http://localhost:3000"; const baseUrl = BASE.endsWith("/") ? BASE : `${BASE}/`; const OWNER_EMAIL = process.env.PWA_OWNER_EMAIL || "owner@agendamax.demo"; const ADMIN_EMAIL = process.env.PWA_ADMIN_EMAIL || "admin@agendamax.demo"; const PWA_PASSWORD = process.env.PWA_PASSWORD || "demo1234"; const NETWORK_TIMEOUT_MS = 8000; const UI_TIMEOUT_MS = 8000; function url(pathname) { return new URL(pathname.replace(/^\//, ""), baseUrl).href; } async function fetchWithTimeout(pathname, timeoutMs = NETWORK_TIMEOUT_MS) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url(pathname), { signal: controller.signal }); const body = await response.text(); return { response, body }; } finally { clearTimeout(timeout); } } async function assertEndpoint(pathname) { const { response, body } = await fetchWithTimeout(pathname); assert.equal(response.status, 200, `${pathname} returned ${response.status}`); return { response, body }; } async function waitForServiceWorker(page) { await page.evaluate(async () => { const timeout = new Promise((_, reject) => { setTimeout(() => reject(new Error("Timed out waiting for service worker readiness")), 5000); }); await Promise.race([navigator.serviceWorker.ready, timeout]); }); } async function assertServiceWorkerApiBypass(page, context) { await page.reload({ waitUntil: "networkidle" }); await waitForServiceWorker(page); await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, { timeout: UI_TIMEOUT_MS }); const apiRequests = async () => page.evaluate(async (timeoutMs) => { async function request(pathname, init) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(pathname, { ...init, signal: controller.signal }); return { status: response.status, body: await response.text() }; } catch (error) { return { networkError: true, errorName: error instanceof Error ? error.name : String(error) }; } finally { clearTimeout(timeout); } } return { get: await request("/api/health"), post: await request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "invalid@agendamax.invalid", password: "invalid" }), }), }; }, NETWORK_TIMEOUT_MS); const online = await apiRequests(); assert.equal(online.get.status, 200, "Online API GET should reach /api/health"); assert.equal(online.post.status, 401, "Online API POST should reach invalid login"); await context.setOffline(true); try { const offline = await apiRequests(); assert.equal(offline.get.networkError, true, "Offline API GET returned a response instead of failing"); assert.equal(offline.post.networkError, true, "Offline API POST returned a response instead of failing"); } finally { await context.setOffline(false); } } async function assertInstallAction(page, expectedVisible, surface = "page") { const action = page.getByRole("button", { name: "Instalar AgendaMax" }); if (expectedVisible) { await page.waitForFunction( () => document.querySelectorAll('button[aria-label="Instalar AgendaMax"]').length === 1, undefined, { timeout: UI_TIMEOUT_MS } ); assert.equal(await action.count(), 1, `Expected exactly one install action on ${surface}`); await action.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); } else { await page.waitForFunction( () => document.querySelectorAll('button[aria-label="Instalar AgendaMax"]').length === 0, undefined, { timeout: UI_TIMEOUT_MS } ); assert.equal(await action.count(), 0, `Install action should not be visible on ${surface}`); } return action; } async function dispatchInstallPrompt(page) { await page.waitForFunction(() => { const selector = 'button[aria-label="Instalar AgendaMax"]'; if (document.querySelectorAll(selector).length === 1) return true; const event = new Event("beforeinstallprompt", { cancelable: true }); event.prompt = async () => { window.__installPromptCalls += 1; }; event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" }); window.dispatchEvent(event); return false; }, undefined, { timeout: UI_TIMEOUT_MS }); } async function dispatchDismissedInstallPrompt(page) { await page.evaluate(() => { const event = new Event("beforeinstallprompt", { cancelable: true }); event.prompt = async () => { window.__installPromptCalls += 1; }; event.userChoice = Promise.resolve({ outcome: "dismissed", platform: "web" }); window.dispatchEvent(event); }); } async function login(page, email, expectedPath) { await page.fill('input[type="email"]', email); await page.fill('input[type="password"]', PWA_PASSWORD); await page.click('button[type="submit"]'); await page.waitForURL(expectedPath, { timeout: UI_TIMEOUT_MS }); await page.waitForSelector("header", { state: "attached", timeout: UI_TIMEOUT_MS }); } async function assertVisibleText(page, text) { const locator = page.getByText(text, { exact: true }); await locator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); assert.equal(await locator.count(), 1, `Expected one visible ${text} instruction`); } async function main() { for (const pathname of ["/manifest.webmanifest", "/sw.js", "/icon-192.png", "/icon-512.png"]) { await assertEndpoint(pathname); } const manifestResult = await assertEndpoint("/manifest.webmanifest"); const manifest = JSON.parse(manifestResult.body); assert.equal(manifest.display, "standalone"); assert.equal(manifest.start_url, "/"); assert.equal(manifest.scope, "/"); assert.ok(manifest.icons?.some((icon) => icon.sizes === "192x192"), "Manifest lacks 192x192 icon"); assert.ok(manifest.icons?.some((icon) => icon.sizes === "512x512"), "Manifest lacks 512x512 icon"); const spaResult = await assertEndpoint("/calendar"); const spaHtml = spaResult.body; assert.match(spaHtml, /
<\/div>/, "SPA route did not return built HTML"); assert.doesNotMatch(spaHtml, /Cannot GET|404 Not Found/i, "SPA route returned a 404 page"); const healthResult = await assertEndpoint("/api/health"); const healthResponse = healthResult.response; assert.match(healthResponse.headers.get("content-type") || "", /application\/json/); const health = JSON.parse(healthResult.body); assert.equal(health.ok, true, "Health endpoint did not return ok=true"); const browser = await chromium.launch(); const contexts = []; try { const chromiumContext = await browser.newContext(); contexts.push(chromiumContext); chromiumContext.setDefaultTimeout(UI_TIMEOUT_MS); chromiumContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS); await chromiumContext.addInitScript(() => { window.__installPromptCalls = 0; window.addEventListener("load", () => setTimeout(() => { const event = new Event("beforeinstallprompt", { cancelable: true }); event.prompt = async () => { window.__installPromptCalls += 1; }; event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" }); window.dispatchEvent(event); }, 100), { once: true }); }); const chromiumPage = await chromiumContext.newPage(); const apiRequests = []; chromiumPage.on("request", (request) => { if (new URL(request.url()).pathname === "/api/health") apiRequests.push(request); }); await chromiumPage.goto(baseUrl, { waitUntil: "networkidle" }); await waitForServiceWorker(chromiumPage); const action = await assertInstallAction(chromiumPage, true, "login"); await action.click(); await chromiumPage.waitForFunction(() => window.__installPromptCalls === 1); assert.equal(await chromiumPage.evaluate(() => window.__installPromptCalls), 1); const dismissalContext = await browser.newContext(); contexts.push(dismissalContext); dismissalContext.setDefaultTimeout(UI_TIMEOUT_MS); dismissalContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS); await dismissalContext.addInitScript(() => { window.__installPromptCalls = 0; }); const dismissalPage = await dismissalContext.newPage(); await dismissalPage.goto(baseUrl, { waitUntil: "networkidle" }); await dispatchDismissedInstallPrompt(dismissalPage); const dismissalAction = await assertInstallAction(dismissalPage, true, "dismissal"); await dismissalAction.click(); await assertInstallAction(dismissalPage, false, "dismissed prompt"); assert.equal(await dismissalPage.evaluate(() => sessionStorage.getItem("agendamax-install-dismissed")), "1"); await dismissalPage.reload({ waitUntil: "networkidle" }); assert.equal(await dismissalPage.evaluate(() => sessionStorage.getItem("agendamax-install-dismissed")), "1"); await dispatchDismissedInstallPrompt(dismissalPage); await assertInstallAction(dismissalPage, false, "dismissed prompt after reload"); const apiResult = await chromiumPage.evaluate(async () => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); try { const response = await fetch("/api/health", { signal: controller.signal }); return { status: response.status, contentType: response.headers.get("content-type"), body: await response.json() }; } finally { clearTimeout(timeout); } }); assert.equal(apiResult.status, 200); assert.match(apiResult.contentType || "", /application\/json/); assert.equal(apiResult.body.ok, true); assert.ok(apiRequests.length > 0, "Browser did not reach the real /api/health endpoint"); await assertServiceWorkerApiBypass(chromiumPage, chromiumContext); const businessPage = await chromiumContext.newPage(); await businessPage.setViewportSize({ width: 375, height: 720 }); await businessPage.goto(baseUrl, { waitUntil: "networkidle" }); await login(businessPage, OWNER_EMAIL, /\/dashboard/); await dispatchInstallPrompt(businessPage); const businessAction = await assertInstallAction(businessPage, true, "business shell"); await businessAction.click(); await businessPage.waitForFunction(() => window.__installPromptCalls === 1); const iosContext = await browser.newContext({ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, }); contexts.push(iosContext); iosContext.setDefaultTimeout(UI_TIMEOUT_MS); iosContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS); await iosContext.addInitScript(() => { Object.defineProperty(Navigator.prototype, "standalone", { configurable: true, get: () => false }); }); const iosPage = await iosContext.newPage(); await iosPage.goto(baseUrl, { waitUntil: "networkidle" }); const iosAction = await assertInstallAction(iosPage, true, "iOS"); await iosAction.click(); await assertVisibleText(iosPage, "Compartir"); await assertVisibleText(iosPage, "AƱadir a pantalla de inicio"); const adminContext = await browser.newContext({ viewport: { width: 375, height: 720 } }); contexts.push(adminContext); adminContext.setDefaultTimeout(UI_TIMEOUT_MS); adminContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS); await adminContext.addInitScript(() => { window.__installPromptCalls = 0; }); const adminPage = await adminContext.newPage(); await adminPage.goto(baseUrl, { waitUntil: "networkidle" }); await login(adminPage, ADMIN_EMAIL, /\/admin/); await dispatchInstallPrompt(adminPage); const adminAction = await assertInstallAction(adminPage, true, "admin shell"); await adminAction.click(); await adminPage.waitForFunction(() => window.__installPromptCalls === 1); const standaloneContext = await browser.newContext(); contexts.push(standaloneContext); standaloneContext.setDefaultTimeout(UI_TIMEOUT_MS); standaloneContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS); await standaloneContext.addInitScript(() => { const originalMatchMedia = window.matchMedia.bind(window); window.matchMedia = (query) => query === "(display-mode: standalone)" ? { matches: true, media: query, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false; } } : originalMatchMedia(query); }); const standalonePage = await standaloneContext.newPage(); await standalonePage.goto(baseUrl, { waitUntil: "networkidle" }); await assertInstallAction(standalonePage, false, "standalone"); const unsupportedContext = await browser.newContext(); contexts.push(unsupportedContext); unsupportedContext.setDefaultTimeout(UI_TIMEOUT_MS); unsupportedContext.setDefaultNavigationTimeout(UI_TIMEOUT_MS); const unsupportedPage = await unsupportedContext.newPage(); await unsupportedPage.goto(baseUrl, { waitUntil: "networkidle" }); await assertInstallAction(unsupportedPage, false, "unsupported"); const swSource = (await assertEndpoint("/sw.js")).body; assert.match(swSource, /pathname\s*\.\s*startsWith\s*\(\s*["']\/api\/["']\s*\)/, "Service worker lacks API bypass"); assert.match(swSource, /request\s*\.\s*method\s*!==\s*["']GET["']/, "Service worker lacks non-GET guard"); assert.match(swSource, /request\.credentials\s*!==\s*["']omit["']/, "Service worker lacks credential bypass"); assert.match(swSource, /key\.startsWith\(["']agendamax-shell-["']\)/, "Service worker cleanup is not scoped to AgendaMax caches"); console.log("PWA checks passed"); } finally { try { for (const context of contexts.reverse()) await context.close(); } finally { await browser.close(); } } } main().catch((error) => { console.error(`PWA checks failed: ${error.message}`); process.exitCode = 1; });