fix: address final PWA review findings

This commit is contained in:
AgendaPro Dev
2026-07-27 17:19:45 -06:00
parent 43f5d1374c
commit 0b466f33f3
5 changed files with 89 additions and 11 deletions
+9
View File
@@ -59,6 +59,15 @@ npm start # sirve la API y el frontend estático en http://localhost:300
- Validación local: ejecuta `npm.cmd run build`, inicia producción con `npm.cmd start` y después ejecuta `npm.cmd run test:pwa`. - Validación local: ejecuta `npm.cmd run build`, inicia producción con `npm.cmd start` y después ejecuta `npm.cmd run test:pwa`.
- La validación de producción requiere el dominio HTTPS de Coolify, no una dirección IP HTTP. - La validación de producción requiere el dominio HTTPS de Coolify, no una dirección IP HTTP.
`npm.cmd run test:pwa` usa estas variables opcionales para los flujos autenticados;
si no se definen, usa las cuentas demo locales:
```text
[email protected]
[email protected]
PWA_PASSWORD=demo1234
```
## Tests y auditoría visual ## Tests y auditoría visual
```bash ```bash
+7 -3
View File
@@ -10,7 +10,11 @@ self.addEventListener("install", (event) => {
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys() caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) .then((keys) => Promise.all(
keys
.filter((key) => key.startsWith("agendamax-shell-") && key !== CACHE_NAME)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim()) .then(() => self.clients.claim())
); );
}); });
@@ -24,7 +28,7 @@ self.addEventListener("fetch", (event) => {
event.respondWith( event.respondWith(
fetch(request) fetch(request)
.then((response) => { .then((response) => {
if (response.ok) { if (request.credentials === "omit" && response.ok) {
const copy = response.clone(); const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
} }
@@ -36,7 +40,7 @@ self.addEventListener("fetch", (event) => {
} }
const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]); const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]);
if (!cacheableDestination.has(request.destination)) return; if (request.credentials !== "omit" || !cacheableDestination.has(request.destination)) return;
event.respondWith( event.respondWith(
caches.match(request).then((cached) => { caches.match(request).then((cached) => {
+34 -3
View File
@@ -3,6 +3,9 @@ import { chromium } from "playwright";
const BASE = process.env.PWA_BASE_URL || "http://localhost:3000"; const BASE = process.env.PWA_BASE_URL || "http://localhost:3000";
const baseUrl = BASE.endsWith("/") ? BASE : `${BASE}/`; const baseUrl = BASE.endsWith("/") ? BASE : `${BASE}/`;
const OWNER_EMAIL = process.env.PWA_OWNER_EMAIL || "[email protected]";
const ADMIN_EMAIL = process.env.PWA_ADMIN_EMAIL || "[email protected]";
const PWA_PASSWORD = process.env.PWA_PASSWORD || "demo1234";
const NETWORK_TIMEOUT_MS = 8000; const NETWORK_TIMEOUT_MS = 8000;
const UI_TIMEOUT_MS = 8000; const UI_TIMEOUT_MS = 8000;
@@ -113,9 +116,18 @@ async function dispatchInstallPrompt(page) {
}, undefined, { timeout: UI_TIMEOUT_MS }); }, 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) { async function login(page, email, expectedPath) {
await page.fill('input[type="email"]', email); await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "demo1234"); await page.fill('input[type="password"]', PWA_PASSWORD);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL(expectedPath, { timeout: UI_TIMEOUT_MS }); await page.waitForURL(expectedPath, { timeout: UI_TIMEOUT_MS });
await page.waitForSelector("header", { state: "attached", timeout: UI_TIMEOUT_MS }); await page.waitForSelector("header", { state: "attached", timeout: UI_TIMEOUT_MS });
@@ -179,6 +191,23 @@ async function main() {
await chromiumPage.waitForFunction(() => window.__installPromptCalls === 1); await chromiumPage.waitForFunction(() => window.__installPromptCalls === 1);
assert.equal(await chromiumPage.evaluate(() => 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 apiResult = await chromiumPage.evaluate(async () => {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 8000);
@@ -199,7 +228,7 @@ async function main() {
const businessPage = await chromiumContext.newPage(); const businessPage = await chromiumContext.newPage();
await businessPage.setViewportSize({ width: 375, height: 720 }); await businessPage.setViewportSize({ width: 375, height: 720 });
await businessPage.goto(baseUrl, { waitUntil: "networkidle" }); await businessPage.goto(baseUrl, { waitUntil: "networkidle" });
await login(businessPage, "[email protected]", /\/dashboard/); await login(businessPage, OWNER_EMAIL, /\/dashboard/);
await dispatchInstallPrompt(businessPage); await dispatchInstallPrompt(businessPage);
const businessAction = await assertInstallAction(businessPage, true, "business shell"); const businessAction = await assertInstallAction(businessPage, true, "business shell");
await businessAction.click(); await businessAction.click();
@@ -231,7 +260,7 @@ async function main() {
await adminContext.addInitScript(() => { window.__installPromptCalls = 0; }); await adminContext.addInitScript(() => { window.__installPromptCalls = 0; });
const adminPage = await adminContext.newPage(); const adminPage = await adminContext.newPage();
await adminPage.goto(baseUrl, { waitUntil: "networkidle" }); await adminPage.goto(baseUrl, { waitUntil: "networkidle" });
await login(adminPage, "[email protected]", /\/admin/); await login(adminPage, ADMIN_EMAIL, /\/admin/);
await dispatchInstallPrompt(adminPage); await dispatchInstallPrompt(adminPage);
const adminAction = await assertInstallAction(adminPage, true, "admin shell"); const adminAction = await assertInstallAction(adminPage, true, "admin shell");
await adminAction.click(); await adminAction.click();
@@ -262,6 +291,8 @@ async function main() {
const swSource = (await assertEndpoint("/sw.js")).body; 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, /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\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"); console.log("PWA checks passed");
} finally { } finally {
try { try {
+6 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useId } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "../lib/format"; import { cn } from "../lib/format";
@@ -14,6 +14,7 @@ interface ModalProps {
} }
export function Modal({ open, onClose, title, subtitle, children, size = "md", footer }: ModalProps) { export function Modal({ open, onClose, title, subtitle, children, size = "md", footer }: ModalProps) {
const titleId = useId();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -35,6 +36,9 @@ export function Modal({ open, onClose, title, subtitle, children, size = "md", f
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center"> <div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={onClose} /> <div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div <div
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
className={cn( className={cn(
"relative z-10 flex max-h-[92vh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl", "relative z-10 flex max-h-[92vh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl",
maxW maxW
@@ -43,7 +47,7 @@ export function Modal({ open, onClose, title, subtitle, children, size = "md", f
{(title || subtitle) && ( {(title || subtitle) && (
<div className="flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4"> <div className="flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
{title && <h2 className="text-lg font-bold text-slate-900">{title}</h2>} {title && <h2 id={titleId} className="text-lg font-bold text-slate-900">{title}</h2>}
{subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>} {subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>}
</div> </div>
<button <button
+33 -3
View File
@@ -7,6 +7,32 @@ interface BeforeInstallPromptEvent extends Event {
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
} }
const DISMISSED_STORAGE_KEY = "agendamax-install-dismissed";
function hasDismissedPrompt() {
try {
return window.sessionStorage.getItem(DISMISSED_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function clearDismissedPrompt() {
try {
window.sessionStorage.removeItem(DISMISSED_STORAGE_KEY);
} catch {
// Storage can be unavailable in privacy-restricted browsers.
}
}
function persistDismissedPrompt() {
try {
window.sessionStorage.setItem(DISMISSED_STORAGE_KEY, "1");
} catch {
// Storage can be unavailable in privacy-restricted browsers.
}
}
function isStandalone() { function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches || return window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone)); ("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone));
@@ -25,7 +51,7 @@ export function useInstallPrompt() {
return isIOSSafari() ? "ios-instructions" : "unsupported"; return isIOSSafari() ? "ios-instructions" : "unsupported";
}); });
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null); const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(() => typeof window !== "undefined" && hasDismissedPrompt());
const installInFlight = useRef(false); const installInFlight = useRef(false);
useEffect(() => { useEffect(() => {
@@ -37,10 +63,11 @@ export function useInstallPrompt() {
const onBeforeInstallPrompt = (event: Event) => { const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault(); event.preventDefault();
setDeferred(event as BeforeInstallPromptEvent); setDeferred(event as BeforeInstallPromptEvent);
setState("available"); if (!hasDismissedPrompt()) setState("available");
}; };
const onInstalled = () => { const onInstalled = () => {
setDeferred(null); setDeferred(null);
clearDismissedPrompt();
setDismissed(false); setDismissed(false);
setState("installed"); setState("installed");
}; };
@@ -62,7 +89,10 @@ export function useInstallPrompt() {
await event.prompt(); await event.prompt();
const choice = await event.userChoice; const choice = await event.userChoice;
if (choice.outcome === "accepted") setState("installed"); if (choice.outcome === "accepted") setState("installed");
else setDismissed(true); else {
persistDismissedPrompt();
setDismissed(true);
}
} catch { } catch {
setDeferred(event); setDeferred(event);
setState("available"); setState("available");