/* Reading without a connection. * * The claim being tested is the one the app makes by being a PWA at all: a note * you have seen is a note you can still read on a train. That has two halves, * and both have to hold — the application itself has to load from the cache, and * the notes have to already be on the device rather than one request away. * * "Offline" here means the browser context is switched offline, which is what * losing a connection actually looks like to the page: requests fail, the * service worker still answers, IndexedDB still works. */ import { api, check, createNote, createNotebook, launch, report, search, setTitle, typeInEditor, withFreshUser, } from "./lib.mjs"; const browser = await launch(); let session; try { session = await withFreshUser(browser, "offline"); const p = session.page; const context = p.context(); await createNotebook(p, "Unterwegs"); await p.locator("button").filter({ hasText: "Unterwegs" }).first().click(); await p.waitForTimeout(700); for (const [title, body] of [ ["Reiseplan", "Abfahrt um sieben Uhr vom Hauptbahnhof."], ["Einkauf", "Milch, Brot, Kaffee."], ]) { await createNote(p); await setTitle(p, title); await typeInEditor(p, body); await p.waitForTimeout(900); } /* The service worker has to be in charge before it can answer anything. */ const controlled = await p.evaluate(async () => { if (!("serviceWorker" in navigator)) return "unsupported"; const reg = await navigator.serviceWorker.ready; return reg.active ? "active" : "no active worker"; }); check(controlled === "active", "a service worker is installed and running", controlled); /* Bodies reach the device on demand, so a note never opened would not be readable offline. The background backfill exists for exactly that; give it a moment and then confirm every note is really in local storage. */ await p.waitForTimeout(4000); const cached = await p.evaluate( () => new Promise((resolve) => { const open = indexedDB.open("inknote"); open.onerror = () => resolve({ error: String(open.error) }); open.onsuccess = () => { const idb = open.result; if (!idb.objectStoreNames.contains("noteContents")) return resolve({ error: "no store" }); const tx = idb.transaction("noteContents", "readonly"); const all = tx.objectStore("noteContents").getAll(); all.onsuccess = () => resolve({ count: all.result.length, texts: all.result.map((r) => String(r.content || "")), }); all.onerror = () => resolve({ error: String(all.error) }); }; }) ); check(!cached.error, "the local copy of the notes can be read", cached.error || ""); check(cached.count >= 2, "every note's body is on the device, not only the open one", `${cached.count} bodies`); check( cached.texts.some((t) => t.includes("Hauptbahnhof")), "including one that was written earlier and then left" ); /* ── pull the plug ── */ await context.setOffline(true); await p.waitForTimeout(500); const blocked = await p.evaluate(async () => { try { await fetch("/api/notes", { cache: "no-store" }); return "still answering"; } catch { return "blocked"; } }); check(blocked === "blocked", "the network really is gone", blocked); /* A cold start: not a soft re-render, a full page load with no connection. */ await p.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); await p.waitForTimeout(4000); const loaded = await p.locator("body").innerText(); check(loaded.length > 40, "the app itself loads from the cache with no connection", `${loaded.length} chars`); check(!/Sign In/i.test(loaded), "and the session is still there — no login screen offline"); await p.locator("button").filter({ hasText: "Unterwegs" }).first().click(); await p.waitForTimeout(1500); const rows = await p.locator("[data-note-row]").count(); check(rows >= 2, "the notebook still lists its notes", `${rows} rows`); await p.locator("[data-note-row]").filter({ hasText: "Reiseplan" }).first().click(); await p.waitForTimeout(2500); const body = await p.locator(".ProseMirror").innerText(); check(body.includes("Hauptbahnhof"), "and a note opens with its text", JSON.stringify(body.slice(0, 60))); /* Searching is local by design — the server holds only ciphertext — so it has to keep working when the server is not there at all. */ await search(p, "Kaffee"); const hits = await p.locator("[data-note-row]").count(); check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`); await search(p, ""); /* Writing offline is queued, not lost. */ await p.locator("[data-note-row]").filter({ hasText: "Einkauf" }).first().click(); await p.waitForTimeout(1800); await p.locator(".ProseMirror").click(); await p.keyboard.press("Control+End"); await p.keyboard.type(" Und Butter."); await p.waitForTimeout(2500); const status = await p.locator("body").innerText(); check(/offline|Pending|Wartet|Änderungen/i.test(status), "the app says it is offline rather than pretending"); /* ── back on ── */ await context.setOffline(false); await p.waitForTimeout(1000); await p.evaluate(() => window.dispatchEvent(new Event("online"))); await p.waitForTimeout(8000); const server = (await api(p, "GET", "/notes")).body; const einkauf = server.find((n) => n.title === "Einkauf"); const stored = await api(p, "GET", `/notes/${einkauf.id}`); check( String(stored.body.content).includes("Butter"), "and the edit made offline reaches the server once there is a connection", String(stored.body.content).slice(0, 80) ); } catch (e) { check(false, "suite ran to the end", e.message); } finally { await session?.cleanup(); const code = report(); await browser.close(); process.exit(code); }