The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
6.0 KiB
JavaScript
157 lines
6.0 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,
|
|
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 p.getByPlaceholder("Search notes...").fill("Kaffee");
|
|
await p.waitForTimeout(1500);
|
|
const hits = await p.locator("[data-note-row]").count();
|
|
check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`);
|
|
await p.getByPlaceholder("Search notes...").fill("");
|
|
await p.waitForTimeout(800);
|
|
|
|
/* 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);
|
|
}
|