Files
InkNote/e2e/offline.mjs
T
marcandClaude Opus 5 52bd2b8f3d Put the interface into English, and give the notebook one way in
The interface had drifted into two languages — the frame in English, everything
added later in German — which is worse than either, since the same idea appeared
under two names depending on when that part was written. Sorting by title still
uses the German collation, so Übung 2 comes before Übung 10: that is about the
notes, not the chrome.

Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ →
drawings/). The importer reads both spellings, and both the old and new marker
line for a drawing — archives exported before this are on disk, and an import
that quietly dropped their drawings would be the worst kind of tidy-up.

Making a note, uploading a PDF and importing Markdown were three buttons side by
side, two of them icons you had to hover to identify. They are the same act —
putting something new in this notebook — so they are now one + menu.

The search box moves out of the sidebar and into the note list, labelled with the
notebook it narrows. Nothing changed about what it does: it has always searched
only the notebook you are in, but from the sidebar it read as searching
everything.

A separator no longer carries a sentence explaining the order the sort menu
already names. Renaming joins removing on the right-click menu — a left-click on
the label started an edit, so the row could not be touched without falling into
one.

218 unit tests, 238 browser checks across eleven suites. Two of those caught real
breakage from this change rather than needing a translation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:17:22 +02:00

156 lines
5.9 KiB
JavaScript

/* 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);
}