From a8becf07d6ecde944421cf7565431c77d2e37b65 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Mon, 27 Jul 2026 16:57:40 -0600 Subject: [PATCH] test: harden AgendaMax PWA checks --- pwa-e2e.mjs | 149 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 29 deletions(-) diff --git a/pwa-e2e.mjs b/pwa-e2e.mjs index 2d185e7..b087d94 100644 --- a/pwa-e2e.mjs +++ b/pwa-e2e.mjs @@ -3,15 +3,29 @@ import { chromium } from "playwright"; const BASE = process.env.PWA_BASE_URL || "http://localhost:3000"; const baseUrl = BASE.endsWith("/") ? BASE : `${BASE}/`; +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 = await fetch(url(pathname)); + const { response, body } = await fetchWithTimeout(pathname); assert.equal(response.status, 200, `${pathname} returned ${response.status}`); - return response; + return { response, body }; } async function waitForServiceWorker(page) { @@ -23,43 +37,84 @@ async function waitForServiceWorker(page) { }); } -async function assertInstallAction(page, expectedVisible) { +async function assertInstallAction(page, expectedVisible, surface = "page") { const action = page.getByRole("button", { name: "Instalar AgendaMax" }); if (expectedVisible) { - await action.waitFor({ state: "visible", timeout: 5000 }); + 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.waitForTimeout(300); - assert.equal(await action.count(), 0, "Install action should not be visible"); + 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 login(page, email, expectedPath) { + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', "demo1234"); + 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 manifestResponse = await assertEndpoint("/manifest.webmanifest"); - const manifest = await manifestResponse.json(); + 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 spaResponse = await assertEndpoint("/calendar"); - const spaHtml = await spaResponse.text(); + 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 healthResponse = await assertEndpoint("/api/health"); + const healthResult = await assertEndpoint("/api/health"); + const healthResponse = healthResult.response; assert.match(healthResponse.headers.get("content-type") || "", /application\/json/); - const health = await healthResponse.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(() => { @@ -76,20 +131,34 @@ async function main() { }); await chromiumPage.goto(baseUrl, { waitUntil: "networkidle" }); await waitForServiceWorker(chromiumPage); - const action = await assertInstallAction(chromiumPage, true); + 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 apiResult = await chromiumPage.evaluate(async () => { - const response = await fetch("/api/health"); - return { status: response.status, contentType: response.headers.get("content-type"), body: await response.json() }; + 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 chromiumContext.close(); + + const businessPage = await chromiumContext.newPage(); + await businessPage.setViewportSize({ width: 375, height: 720 }); + await businessPage.goto(baseUrl, { waitUntil: "networkidle" }); + await login(businessPage, "owner@agendamax.demo", /\/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", @@ -97,19 +166,36 @@ async function main() { 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); + const iosAction = await assertInstallAction(iosPage, true, "iOS"); await iosAction.click(); - const iosText = await iosPage.locator("body").textContent(); - assert.match(iosText || "", /Compartir/); - assert.match(iosText || "", /Añadir a pantalla de inicio/); - await iosContext.close(); + 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@agendamax.demo", /\/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)" @@ -118,21 +204,26 @@ async function main() { }); const standalonePage = await standaloneContext.newPage(); await standalonePage.goto(baseUrl, { waitUntil: "networkidle" }); - await assertInstallAction(standalonePage, false); - await standaloneContext.close(); + 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); - await unsupportedContext.close(); + await assertInstallAction(unsupportedPage, false, "unsupported"); - const swSource = await (await fetch(url("/sw.js"))).text(); - assert.match(swSource, /url\.pathname\.startsWith\("\/api\/"\)/, "Service worker lacks API bypass"); - assert.match(swSource, /request\.method\s*!==\s*"GET"/, "Service worker lacks non-GET guard"); + 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"); console.log("PWA checks passed"); } finally { - await browser.close(); + try { + for (const context of contexts.reverse()) await context.close(); + } finally { + await browser.close(); + } } }