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>
82 lines
3.4 KiB
JavaScript
82 lines
3.4 KiB
JavaScript
/* Two devices editing the same note without knowing about each other.
|
|
*
|
|
* The write that arrives last wins — refusing it would throw away what was just
|
|
* typed — but the state it buries has to survive as a version, or the other
|
|
* device's work is simply gone. That is the whole point of the check, so this
|
|
* verifies it end to end rather than trusting the route in isolation. */
|
|
import {
|
|
api,
|
|
check,
|
|
createNote,
|
|
createNotebook,
|
|
launch,
|
|
report,
|
|
setTitle,
|
|
typeInEditor,
|
|
withFreshUser,
|
|
} from "./lib.mjs";
|
|
|
|
const browser = await launch();
|
|
let session;
|
|
try {
|
|
session = await withFreshUser(browser, "conflict");
|
|
const p = session.page;
|
|
await createNotebook(p, "Konflikt Buch");
|
|
await createNote(p);
|
|
await setTitle(p, "Umkämpft");
|
|
await typeInEditor(p, "Ursprünglicher Text von Gerät A.");
|
|
await p.waitForTimeout(2000);
|
|
|
|
const id = (await api(p, "GET", "/notes")).body[0].id;
|
|
const base = (await api(p, "GET", `/notes/${id}`)).body.updated_at;
|
|
check(!!base, "the note reports a revision to base an edit on", String(base));
|
|
|
|
/* Device B saves — it has seen the same revision, so no conflict. */
|
|
const first = await api(p, "PUT", `/notes/${id}`, {
|
|
content: JSON.stringify({ v: 2, doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Gerät B war hier." }] }] } }),
|
|
baseUpdatedAt: base,
|
|
});
|
|
check(first.status === 200, "a save on the current revision is accepted", String(first.status));
|
|
check(first.body.conflict === false, "and is not reported as a conflict", JSON.stringify(first.body.conflict));
|
|
check(!!first.body.updatedAt, "the server answers with the new revision");
|
|
|
|
/* Device A saves, still believing the note is at the original revision. */
|
|
const stale = await api(p, "PUT", `/notes/${id}`, {
|
|
content: JSON.stringify({ v: 2, doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Gerät A schreibt spät." }] }] } }),
|
|
baseUpdatedAt: base,
|
|
});
|
|
check(stale.status === 200, "the late save is still accepted, not rejected", String(stale.status));
|
|
check(stale.body.conflict === true, "but it is reported as a conflict");
|
|
|
|
/* The late write won... */
|
|
const now = (await api(p, "GET", `/notes/${id}`)).body;
|
|
check(now.content.includes("Gerät A schreibt spät"), "the later write is what the note now holds");
|
|
|
|
/* ...and the buried version is recoverable. */
|
|
const versions = (await api(p, "GET", `/notes/${id}/versions`)).body;
|
|
check(
|
|
versions.some((v) => v.reason === "conflict"),
|
|
"a version is kept and labelled as a conflict",
|
|
JSON.stringify(versions.map((v) => v.reason))
|
|
);
|
|
|
|
const conflictVersion = versions.find((v) => v.reason === "conflict");
|
|
const buried = (await api(p, "GET", `/notes/${id}/versions/${conflictVersion.id}`)).body;
|
|
check(
|
|
buried.content.includes("Gerät B war hier"),
|
|
"and it holds exactly the work the late write buried"
|
|
);
|
|
|
|
/* A write with no base at all must not be treated as a conflict — that is how
|
|
* writes that cannot conflict, like starring, reach the server. */
|
|
const noBase = await api(p, "PUT", `/notes/${id}`, { starred: true });
|
|
check(noBase.body.conflict === false, "a write without a base revision is never a conflict");
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.message);
|
|
} finally {
|
|
await session?.cleanup();
|
|
const code = report();
|
|
await browser.close();
|
|
process.exit(code);
|
|
}
|