Files
InkNote/e2e/offline.mjs
T
marcandClaude Opus 5 608da16ac2 Make the phone work: touch reordering, reachable menus, and nothing lost offline
Four things that were built for a mouse on a desk and quietly did nothing
useful on a phone. The last one is the serious one.

**A note made offline lost everything written into it.** Its create
reached the server, the server named it something else, and every edit
still queued behind it went on addressing the name only that device ever
knew — 404, five retries, thrown away. The note arrived empty and
untitled, with nothing to say that anything had been lost. The database
was repointed at the new id all along; the queue was not. It is now, by
replacing the id across the whole queued payload rather than in a list of
named fields, because a list of fields is a list to forget to add to. The
same held for a notebook made offline and everything filed into it.

**The order of notes could not be changed on a phone**, because the list
went through the HTML5 drag API, which mobile browsers never fire from
touch. Pointer events instead, which mouse, pen and finger all produce. A
mouse still drags a row from anywhere; a finger drags from a grip,
because pressing the row has to go on scrolling and holding it has to go
on opening the menu. The row under the finger is found with
elementFromPoint — a finger raises no pointerover on anything it did not
press.

**"Move to..." opened off the side of the screen**, and could not be
opened at all without a hover. It is a tap now, unfolding in place where
there is no room beside the menu and flipping sides where there is. The
menu itself is placed by measuring rather than by assuming it is 220px
wide, its rows are tall enough to hit with a thumb, and Escape closes it.

**Installing said nothing when it could not be done.** The entry appeared
only when the browser had offered a prompt, so where installing was
impossible there was no button and no reason. It is always there now and
names the cause — most usefully that a browser will only install a site
served over HTTPS, which is also why the service worker would not have
registered, so one cause was showing up as two mysteries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:16:41 +02:00

204 lines
7.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");
/* ── the rest of what a note is, with no connection ──
* Reading and editing were covered. Making a note and drawing on one were
* not, and those are half of what the app is for on a phone — which is
* exactly where the connection is missing. */
const beforeNew = (await p.locator("[data-note-row]").count());
await createNote(p);
await p.waitForTimeout(1200);
await setTitle(p, "Offline entstanden");
await typeInEditor(p, "Ohne Netz geschrieben.");
await p.waitForTimeout(1500);
check(
(await p.locator("[data-note-row]").count()) > beforeNew,
"a note can be made with no connection",
`${beforeNew} → ${await p.locator("[data-note-row]").count()}`
);
await p.locator("button").filter({ hasText: "Draw" }).first().click();
await p.waitForTimeout(1200);
const canvas = await p.locator('canvas[data-draw-page="1"]').boundingBox();
await p.mouse.move(canvas.x + canvas.width * 0.3, canvas.y + canvas.height * 0.3);
await p.mouse.down();
await p.mouse.move(canvas.x + canvas.width * 0.7, canvas.y + canvas.height * 0.45, { steps: 12 });
await p.mouse.up();
await p.waitForTimeout(2500);
const inkOffline = await p.locator('canvas[data-draw-page="1"]').evaluate((c) => {
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++;
return n;
});
check(inkOffline > 100, "and drawn on, with no connection", `${inkOffline} inked pixels`);
/* ── 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)
);
const made = server.find((n) => n.title === "Offline entstanden");
check(!!made, "the note made offline reaches the server too", made ? made.id : "not there");
if (made) {
const madeBody = await api(p, "GET", `/notes/${made.id}`);
check(
String(madeBody.body.content).includes("Ohne Netz"),
"with what was written in it",
String(madeBody.body.content).slice(0, 80)
);
check(
(madeBody.body.drawingStrokes?.strokes?.length ?? 0) > 0,
"and with the drawing that was made on it",
JSON.stringify(Object.keys(madeBody.body.drawingStrokes || {}))
);
}
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}