The vector clocks in the server were never reached: no client sent one, so the branch was dead and every write was a blind overwrite. Not "the newer edit wins" — "the request that arrives last wins", which is worse: a phone that was offline all day buries the laptop's newer work simply by reconnecting late. Each write now carries baseUpdatedAt, the revision it was written on top of. If the note has moved on since, the incoming edit still wins — a rejection would throw away what was just typed — but the state it replaces is kept as a version marked "conflict", so both exist and the history is where you choose. Clocks could have told a real conflict from a device catching up, but that precision only pays off with a merge interface, and this reuses the version history already built. adoptRevision() stores the revision the server reports back, or the second save in a row would be flagged as conflicting with the first. Also removed, as agreed: - POST /api/notes/pdf — accepted PDFs that nothing could ever read back, with no retrieval route and no viewer. The readme advertised "PDF Import & Annotation"; that claim is gone too. - POST /api/sync — a batch endpoint the client never called. - the tags column — notebooks, starring and search already cover it. Columns on existing databases are left in place. Dropping them buys nothing and is not worth touching a live database for. Tables now have a right-click menu (editor/tableMenu.js) for rows, columns, header and merging. It needs stopPropagation: without it the same event reaches window, where the menu that is mounting reads it as a click elsewhere and closes instantly — the reason the other context menus in App.jsx already do this. 92 browser checks across seven suites, 125 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.5 KiB
JavaScript
141 lines
5.5 KiB
JavaScript
/* Editor: tables, task lists, links between notes, and whether any of it
|
|
* survives a reload — the place where a stale server response used to eat
|
|
* freshly typed content. */
|
|
import {
|
|
OUT,
|
|
check,
|
|
createNote,
|
|
createNotebook,
|
|
insertTable,
|
|
launch,
|
|
reopenNote,
|
|
report,
|
|
setTitle,
|
|
typeInEditor,
|
|
withFreshUser,
|
|
} from "./lib.mjs";
|
|
|
|
const browser = await launch();
|
|
let session;
|
|
try {
|
|
session = await withFreshUser(browser, "editor");
|
|
const p = session.page;
|
|
|
|
await createNotebook(p, "Editor Book");
|
|
await createNote(p);
|
|
await typeInEditor(p, "Grundtext");
|
|
|
|
/* ── tables ── */
|
|
await insertTable(p);
|
|
check((await p.locator(".ProseMirror table").count()) > 0, "table inserted");
|
|
check((await p.locator(".ProseMirror table th").count()) >= 3, "table has a header row");
|
|
|
|
/* ── task list ── */
|
|
await p.locator(".ProseMirror").click();
|
|
await p.keyboard.press("Control+End");
|
|
await p.keyboard.press("Enter");
|
|
await p.getByTitle("Task list").click();
|
|
await p.waitForTimeout(400);
|
|
await p.keyboard.type("Milch kaufen");
|
|
await p.waitForTimeout(1200);
|
|
check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list inserted");
|
|
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
|
|
await p.waitForTimeout(1500);
|
|
|
|
await p.screenshot({ path: `${OUT}/editor-content.png` });
|
|
|
|
/* ── right-click menu on the table ── */
|
|
const cellCount = () => p.locator(".ProseMirror table td, .ProseMirror table th").count();
|
|
const before = await cellCount();
|
|
await p.locator(".ProseMirror table td").first().click({ button: "right" });
|
|
await p.waitForTimeout(600);
|
|
check((await p.getByText("Zeile darunter einfügen").count()) > 0, "right-click in a table opens the table menu");
|
|
check((await p.getByText("Tabelle löschen").count()) > 0, "and offers deleting the table");
|
|
await p.screenshot({ path: `${OUT}/editor-table-menu.png` });
|
|
|
|
await p.getByText("Zeile darunter einfügen").click();
|
|
await p.waitForTimeout(1000);
|
|
check((await cellCount()) > before, "the menu really added a row", `${before} → ${await cellCount()}`);
|
|
|
|
const withRow = await cellCount();
|
|
await p.locator(".ProseMirror table td").first().click({ button: "right" });
|
|
await p.waitForTimeout(600);
|
|
await p.getByText("Zeile löschen").click();
|
|
await p.waitForTimeout(1000);
|
|
check((await cellCount()) < withRow, "and removing one works too", `${withRow} → ${await cellCount()}`);
|
|
|
|
// Outside a table the browser's own menu must stay available.
|
|
await p.locator(".ProseMirror p").first().click({ button: "right" });
|
|
await p.waitForTimeout(500);
|
|
check(
|
|
(await p.getByText("Zeile darunter einfügen").count()) === 0,
|
|
"right-click outside a table shows no table menu"
|
|
);
|
|
await p.keyboard.press("Escape");
|
|
await p.waitForTimeout(400);
|
|
|
|
/* ── a second note to link to ── */
|
|
await setTitle(p, "Quelle");
|
|
await createNote(p);
|
|
await setTitle(p, "Ziel");
|
|
await typeInEditor(p, "Ich bin das Ziel.");
|
|
|
|
await p.locator("button").filter({ hasText: "Quelle" }).first().click();
|
|
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
|
|
await p.waitForTimeout(1200);
|
|
|
|
/* ── note link via the @ menu ── */
|
|
// Control+End lands inside the task list, where a plain Enter would only make
|
|
// another task. A second Enter on the empty item leaves the list, so the link
|
|
// goes into a paragraph of its own and the checked task above stays untouched.
|
|
await p.locator(".ProseMirror").click();
|
|
await p.keyboard.press("Control+End");
|
|
await p.keyboard.press("Enter");
|
|
await p.keyboard.press("Enter");
|
|
await p.waitForTimeout(300);
|
|
await p.keyboard.type("@Ziel");
|
|
await p.waitForTimeout(1400);
|
|
const menuOpen = (await p.locator("text=Ziel").count()) > 0;
|
|
check(menuOpen, "@ opens the link menu");
|
|
await p.keyboard.press("Enter");
|
|
await p.waitForTimeout(1800);
|
|
|
|
const linkCount = await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count();
|
|
check(linkCount > 0, "note link inserted into the document", `${linkCount} found`);
|
|
await p.screenshot({ path: `${OUT}/editor-link.png` });
|
|
|
|
/* ── the server learned about it ── */
|
|
await p.waitForTimeout(1500);
|
|
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
|
|
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
|
|
await p.waitForTimeout(2000);
|
|
const backlink = await p.locator("text=Quelle").count();
|
|
check(backlink > 0, "the target shows a backlink to the source");
|
|
|
|
/* ── everything survives a cold reload ── */
|
|
await reopenNote(p, "Quelle");
|
|
check((await p.locator(".ProseMirror table").count()) > 0, "table survived a reload");
|
|
check(
|
|
(await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count()) > 0,
|
|
"note link survived a reload"
|
|
);
|
|
check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list survived a reload");
|
|
check(
|
|
(await p.locator('.ProseMirror input[type="checkbox"]:checked').count()) === 1,
|
|
"the ticked task stayed ticked, and only that one"
|
|
);
|
|
check(
|
|
(await p.locator(".ProseMirror li").filter({ hasText: "Ziel" }).count()) === 0,
|
|
"the note link went into a paragraph, not into the task list"
|
|
);
|
|
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
|
|
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.message);
|
|
} finally {
|
|
await session?.cleanup();
|
|
const code = report();
|
|
await browser.close();
|
|
process.exit(code);
|
|
}
|