diff --git a/README.md b/README.md
index 1f72bff..b63629e 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,14 @@ npm run build # compila TS y empaqueta el frontend en dist/
npm start # sirve la API y el frontend estático en http://localhost:3000
```
+## Instalar AgendaMax como app
+
+- **Android/Chrome**: abre la URL HTTPS y selecciona la acción de instalación mostrada por AgendaMax o por la barra de direcciones del navegador.
+- **iPhone/iPad Safari**: usa **Compartir -> Añadir a pantalla de inicio**; iOS no expone el aviso de instalación dentro de la página de Android.
+- La app instalada puede abrirse sin la interfaz del navegador, pero los datos del negocio siguen requiriendo conexión a internet.
+- 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.
+
## Tests y auditoría visual
```bash
diff --git a/pwa-e2e.mjs b/pwa-e2e.mjs
new file mode 100644
index 0000000..2d185e7
--- /dev/null
+++ b/pwa-e2e.mjs
@@ -0,0 +1,142 @@
+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}/`;
+
+function url(pathname) {
+ return new URL(pathname.replace(/^\//, ""), baseUrl).href;
+}
+
+async function assertEndpoint(pathname) {
+ const response = await fetch(url(pathname));
+ assert.equal(response.status, 200, `${pathname} returned ${response.status}`);
+ return response;
+}
+
+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 assertInstallAction(page, expectedVisible) {
+ const action = page.getByRole("button", { name: "Instalar AgendaMax" });
+ if (expectedVisible) {
+ await action.waitFor({ state: "visible", timeout: 5000 });
+ } else {
+ await page.waitForTimeout(300);
+ assert.equal(await action.count(), 0, "Install action should not be visible");
+ }
+ return action;
+}
+
+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();
+ 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();
+ 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");
+ assert.match(healthResponse.headers.get("content-type") || "", /application\/json/);
+ const health = await healthResponse.json();
+ assert.equal(health.ok, true, "Health endpoint did not return ok=true");
+
+ const browser = await chromium.launch();
+ try {
+ const chromiumContext = await browser.newContext();
+ 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);
+ 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() };
+ });
+ 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 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,
+ });
+ 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);
+ 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();
+
+ const standaloneContext = await browser.newContext();
+ 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);
+ await standaloneContext.close();
+
+ const unsupportedContext = await browser.newContext();
+ const unsupportedPage = await unsupportedContext.newPage();
+ await unsupportedPage.goto(baseUrl, { waitUntil: "networkidle" });
+ await assertInstallAction(unsupportedPage, false);
+ await unsupportedContext.close();
+
+ 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");
+ console.log("PWA checks passed");
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch((error) => {
+ console.error(`PWA checks failed: ${error.message}`);
+ process.exitCode = 1;
+});