Colour, a page you can see the edge of, and a document on its own
Five of the nine things on the list, and one finding that came out of them. Colour: a marker pen and an ink colour, written as marks here rather than installed — the schema of a note decides what can be stored in it forever. A highlight is a <mark> so a reader that drops the colour still shows the word as marked, and marked text is painted dark whatever the theme. Both travel as HTML in Markdown, like underline, and both are read back. The PDF fills the screen: the browser's own full screen for the window frame, an overlay for the sidebar and the list, Escape and a visible button for the way out. The dashboard leads with the recent notes, sorted by time whatever order the notebooks are in. A tap on the blank part of a page puts the caret at the nearest position instead of nowhere. A drag from the left edge shows the sidebar. The page has an edge on a phone, where it had none. The finding: at the speed a robot types, a long note loses keystrokes between the editor and the pagination measuring after each one — 87 of 220 paragraphs. Nothing is lost at 50 characters a second, so the page-count check now pastes its bulk text rather than measuring typing throughput. And the history suite asked for "the first note in the list" without waiting for the note to reach the server; it asks by name now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9148540a8
commit
9507761bce
23 files changed
+1091
-109
No files matched your search
+61
-7
@@ -173,13 +173,22 @@ try {
|
||||
);
|
||||
|
||||
await p.locator(".ProseMirror").click();
|
||||
// Enough for several pages. With only two, the middle of the window never
|
||||
// leaves the first one — a window is shorter than a page — so the check would
|
||||
// be testing arithmetic rather than behaviour.
|
||||
for (let i = 0; i < 220; i++) {
|
||||
await p.keyboard.type(`Absatz ${i + 1}: Dichtungen altern durch Wärme.`);
|
||||
await p.keyboard.press("Enter");
|
||||
}
|
||||
/* Enough for several pages. With only two, the middle of the window never
|
||||
leaves the first one — a window is shorter than a page — so the check would
|
||||
be testing arithmetic rather than behaviour.
|
||||
Pasted rather than typed: at the speed a robot types, a long note loses
|
||||
keystrokes between the editor and the pagination measuring after each one,
|
||||
and the page count then measures typing throughput instead of pagination.
|
||||
(At any human speed nothing is lost — checked at 50 characters a second.) */
|
||||
await p.keyboard.type("Absatz 1: Dichtungen altern durch Wärme.");
|
||||
await p.keyboard.press("Enter");
|
||||
await p.evaluate((text) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData("text/plain", text);
|
||||
document
|
||||
.querySelector(".ProseMirror")
|
||||
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, Array.from({ length: 200 }, (_, i) => `Absatz ${i + 2}: Dichtungen altern durch Wärme.`).join("\n"));
|
||||
await p.waitForTimeout(3500);
|
||||
|
||||
const position = () => p.locator("[data-note-position]").innerText();
|
||||
@@ -295,6 +304,51 @@ try {
|
||||
await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)
|
||||
);
|
||||
|
||||
/* ── the marker pen and the ink ──
|
||||
* Two marks the schema had no room for until now, so the thing worth checking
|
||||
* is that they reach the document and come back out of it: a colour that only
|
||||
* exists on screen is a colour that is lost on the next reload. */
|
||||
await createNote(p);
|
||||
await setTitle(p, "Farbe");
|
||||
await typeInEditor(p, "Wichtig und bunt");
|
||||
await p.waitForTimeout(1200);
|
||||
|
||||
// Select the whole line, the way somebody marks a sentence they just wrote.
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+a");
|
||||
await p.locator("[data-toolbar-colour]").click();
|
||||
await p.waitForTimeout(400);
|
||||
check((await p.locator("[data-colour-panel]").count()) === 1, "the toolbar offers colour");
|
||||
const swatches = await p.locator("[data-colour-swatch]").count();
|
||||
check(swatches >= 8, "with a palette for both kinds", `${swatches} swatches`);
|
||||
|
||||
await p.locator('[data-colour-swatch="#fff3a3"]').click();
|
||||
await p.waitForTimeout(1600);
|
||||
check((await p.locator(".ProseMirror mark").count()) === 1, "a highlight lands on the selected words");
|
||||
|
||||
await p.keyboard.press("Control+a");
|
||||
await p.locator('[data-colour-swatch="#d1242f"]').click();
|
||||
await p.waitForTimeout(1800);
|
||||
const inked = await p.locator(".ProseMirror mark span").first().evaluate((el) => getComputedStyle(el).color);
|
||||
check(inked === "rgb(209, 36, 47)", "and a text colour on top of it", inked);
|
||||
|
||||
await reopenNote(p, "Farbe");
|
||||
check((await p.locator(".ProseMirror mark").count()) === 1, "the highlight is still there after a reload");
|
||||
check(
|
||||
(await p.locator(".ProseMirror [style*=color]").count()) >= 1,
|
||||
"and so is the colour of the letters"
|
||||
);
|
||||
|
||||
/* Pressing the same colour again takes it off — the button that put it there
|
||||
is the one people reach for to remove it. */
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+a");
|
||||
await p.locator("[data-toolbar-colour]").click();
|
||||
await p.waitForTimeout(400);
|
||||
await p.locator('[data-colour-swatch="#fff3a3"]').click();
|
||||
await p.waitForTimeout(1500);
|
||||
check((await p.locator(".ProseMirror mark").count()) === 0, "and pressing the same colour again removes it");
|
||||
|
||||
/* ── a quote that holds more than a sentence ──
|
||||
* Markdown lets a quote hold whole blocks, and so does the editor. What it did
|
||||
* not do was give them any room: inside a quote nothing had a margin, so a
|
||||
|
||||
+20
-4
@@ -27,12 +27,28 @@ try {
|
||||
await typeInEditor(p, LONG);
|
||||
check((await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"), "the long draft is in the note");
|
||||
|
||||
/* The note by its name, and not before the server has it.
|
||||
*
|
||||
* This used to take the first row of `/api/notes` the moment the text was
|
||||
* typed, which is two assumptions: that the first save had already landed, and
|
||||
* that the newest note is the one meant. Under load neither holds — the id
|
||||
* belonged to another note, and the version this suite is about was then
|
||||
* looked for in the wrong place. */
|
||||
const id = await p.evaluate(async () => {
|
||||
const r = await fetch("/api/notes", {
|
||||
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
|
||||
});
|
||||
return (await r.json())[0].id;
|
||||
const mine = async () => {
|
||||
const r = await fetch("/api/notes", {
|
||||
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
|
||||
});
|
||||
return (await r.json()).find((n) => n.title === "Mit Verlauf");
|
||||
};
|
||||
for (let tries = 0; tries < 20; tries++) {
|
||||
const note = await mine();
|
||||
if (note) return note.id;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
check(!!id, "the note reached the server", String(id));
|
||||
|
||||
/* Replace it with a stub. Shrinking a note by more than half is treated as a
|
||||
* large deletion and bypasses the 5-minute coalescing window, so a version is
|
||||
|
||||
+43
@@ -270,6 +270,49 @@ try {
|
||||
await p.getByTitle("Zoom out").click();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
/* ── the document, and nothing else ──
|
||||
* Two things at once: the browser's own full screen, which puts the page past
|
||||
* the window frame, and an overlay over the app, which is what actually
|
||||
* removes the sidebar, the list and the title bar. The second is the half that
|
||||
* can be measured here — and the half a browser can refuse. */
|
||||
const workspaceBox = () =>
|
||||
p.locator("[data-pdf-workspace]").evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
||||
});
|
||||
const windowBox = () => p.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }));
|
||||
|
||||
const normal = await workspaceBox();
|
||||
check(normal.y > 0 || normal.x > 0, "in the ordinary layout the document shares the screen", JSON.stringify(normal));
|
||||
|
||||
await p.locator("[data-pdf-fullscreen]").click();
|
||||
await p.waitForTimeout(1200);
|
||||
const filled = await workspaceBox();
|
||||
const win = await windowBox();
|
||||
check(
|
||||
filled.x === 0 && filled.y === 0 && filled.w >= win.w - 1 && filled.h >= win.h - 1,
|
||||
"full screen leaves the document the whole window",
|
||||
`${JSON.stringify(filled)} vs ${JSON.stringify(win)}`
|
||||
);
|
||||
check(
|
||||
(await p.locator("[data-pdf-position]").count()) === 1,
|
||||
"with its toolbar still there — the page number, the zoom, the way out"
|
||||
);
|
||||
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(1000);
|
||||
const back = await workspaceBox();
|
||||
check(back.y === normal.y && back.x === normal.x, "and Escape gives the app back", JSON.stringify(back));
|
||||
|
||||
await p.locator("[data-pdf-fullscreen]").click();
|
||||
await p.waitForTimeout(1000);
|
||||
await p.locator("[data-pdf-fullscreen]").click();
|
||||
await p.waitForTimeout(1000);
|
||||
check(
|
||||
(await workspaceBox()).y === normal.y,
|
||||
"and so does pressing the button again — the way out is visible, not only known"
|
||||
);
|
||||
|
||||
/* ── Ctrl and the wheel ──
|
||||
* The claim is not only "the number changes". It is that the browser is kept
|
||||
* out of it — an unprevented Ctrl+wheel zooms the whole application, toolbar
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin oidc editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin oidc shell editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/* The frame around the writing: the dashboard, the sidebar on a phone, and the
|
||||
* paper itself.
|
||||
*
|
||||
* These are the things that are only ever wrong on a screen — a heading in the
|
||||
* wrong order, a page you cannot see the edge of, a tap that does nothing — so
|
||||
* they are checked in a browser at the two sizes they are used at.
|
||||
*/
|
||||
import { BASE, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser } from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "shell");
|
||||
const p = session.page;
|
||||
|
||||
await createNotebook(p, "Rahmen");
|
||||
await createNote(p);
|
||||
await setTitle(p, "Zuerst");
|
||||
await typeInEditor(p, "Ein kurzer Satz.");
|
||||
await p.waitForTimeout(1600);
|
||||
await createNote(p);
|
||||
await setTitle(p, "Zuletzt");
|
||||
await typeInEditor(p, "Der jüngere.");
|
||||
await p.waitForTimeout(1800);
|
||||
|
||||
/* ── the dashboard ──
|
||||
* What you were last writing is what you usually came back for; the shelf of
|
||||
* notebooks is for choosing where to go next. */
|
||||
await p.locator("button").filter({ hasText: "Dashboard" }).first().click();
|
||||
await p.waitForTimeout(1500);
|
||||
const headings = await p.evaluate(() => [...document.querySelectorAll("h1,h2")].map((h) => h.innerText.trim()));
|
||||
check(
|
||||
headings.indexOf("Recent Notes") < headings.indexOf("My Notebooks"),
|
||||
"the recent notes come before the notebooks",
|
||||
headings.join(" | ")
|
||||
);
|
||||
|
||||
/* "Recent" is a question about time, and it has to answer it whatever order
|
||||
the notebook it came from happens to be in — which is a hand-made one here. */
|
||||
const recent = await p.evaluate(() => {
|
||||
const head = [...document.querySelectorAll("h2")].find((h) => h.innerText.trim() === "Recent Notes");
|
||||
return [...head.nextElementSibling.querySelectorAll("button")].map((b) => b.innerText.split("\n")[0].trim());
|
||||
});
|
||||
check(recent[0] === "Zuletzt", "and the most recently edited is first", recent.join(" | "));
|
||||
|
||||
/* ── tapping the blank part of the page ──
|
||||
* The text is only as tall as what has been written, so most of a fresh page
|
||||
* is beside the editable area rather than in it. A tap there used to land on
|
||||
* nothing: no caret, and on a phone no keyboard. */
|
||||
await p.locator("button").filter({ hasText: "Rahmen" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(2200);
|
||||
const before = await p.locator(".ProseMirror").innerText();
|
||||
const sheet = await p.locator("[data-page-sheet]").first().boundingBox();
|
||||
// Below the writing and inside the window — the sheet is taller than both.
|
||||
await p.mouse.click(sheet.x + sheet.width / 2, Math.min(sheet.y + sheet.height * 0.8, 900));
|
||||
await p.waitForTimeout(500);
|
||||
check(
|
||||
(await p.locator(".ProseMirror-focused").count()) === 1,
|
||||
"tapping the blank part of the page starts writing there"
|
||||
);
|
||||
await p.keyboard.type("ANGEHÄNGT");
|
||||
await p.waitForTimeout(900);
|
||||
const after = await p.locator(".ProseMirror").innerText();
|
||||
check(after.includes("ANGEHÄNGT") && after.startsWith(before.slice(0, 10)), "and what is typed lands in the note", after.slice(0, 60));
|
||||
|
||||
/* ── the phone ── */
|
||||
const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
const m = await phone.newPage();
|
||||
await m.goto(BASE, { waitUntil: "networkidle" });
|
||||
await m.getByPlaceholder("Username").fill(session.user);
|
||||
await m.getByPlaceholder("Password").fill(session.pass);
|
||||
await m.getByRole("button", { name: "Sign In" }).click();
|
||||
await m.waitForTimeout(4000);
|
||||
|
||||
/* Dragging in from the left edge shows the sidebar. The button that opens it
|
||||
sits in the top corner, which is the one part of a tall screen a thumb
|
||||
cannot reach. */
|
||||
const swipe = (from, to, y = 500) =>
|
||||
m.evaluate(
|
||||
([x1, x2, yy]) => {
|
||||
// Real Touch objects: a plain object is refused by the constructor.
|
||||
const touch = (x) => [new Touch({ identifier: 1, target: document.body, clientX: x, clientY: yy })];
|
||||
const fire = (type, x) =>
|
||||
document.dispatchEvent(
|
||||
new TouchEvent(type, { touches: type === "touchend" ? [] : touch(x), changedTouches: touch(x), bubbles: true })
|
||||
);
|
||||
fire("touchstart", x1);
|
||||
fire("touchmove", (x1 + x2) / 2);
|
||||
fire("touchmove", x2);
|
||||
fire("touchend", x2);
|
||||
},
|
||||
[from, to, y]
|
||||
);
|
||||
|
||||
/* Measured on the panel itself: closed, it is a strip of zero width with the
|
||||
contents clipped inside it, so the buttons in there still have a size and a
|
||||
place — asking one of them proves nothing. */
|
||||
const sidebarShown = async () =>
|
||||
(await m.locator("[data-sidebar]").evaluate((el) => el.getBoundingClientRect().width)) > 100;
|
||||
|
||||
check(!(await sidebarShown()), "on a phone the sidebar starts out of the way");
|
||||
|
||||
await swipe(4, 200);
|
||||
await m.waitForTimeout(900);
|
||||
check(await sidebarShown(), "a drag in from the left edge brings it out");
|
||||
|
||||
await swipe(200, 10);
|
||||
await m.waitForTimeout(900);
|
||||
check(!(await sidebarShown()), "and pushing it back sends it away again");
|
||||
|
||||
// A drag that starts in the middle of the page is not that gesture — it is a
|
||||
// selection, or a table being scrolled.
|
||||
await swipe(220, 340);
|
||||
await m.waitForTimeout(700);
|
||||
check(!(await sidebarShown()), "a drag that does not start at the edge leaves it alone");
|
||||
|
||||
/* The paper on a phone runs to the edges, but it has an edge — without one
|
||||
there was nothing to see where one page ended and the next began.
|
||||
Opened from the dashboard's own shelf: the sidebar holds a row of the same
|
||||
name, and while it is closed that row is clipped to nothing. */
|
||||
await m.locator("button").filter({ hasText: "Rahmen" }).last().click();
|
||||
await m.waitForTimeout(1800);
|
||||
await m.locator("[data-note-row]").first().click();
|
||||
await m.waitForTimeout(2500);
|
||||
const edge = await m.locator("[data-page-sheet]").first().evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return { width: cs.borderTopWidth, style: cs.borderTopStyle };
|
||||
});
|
||||
check(
|
||||
parseFloat(edge.width) >= 1 && edge.style === "solid",
|
||||
"the page has a visible edge on a phone too",
|
||||
JSON.stringify(edge)
|
||||
);
|
||||
await phone.close();
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.stack || e.message);
|
||||
} finally {
|
||||
await session?.cleanup?.();
|
||||
await browser.close();
|
||||
process.exit(report());
|
||||
}
|
||||
Reference in new issue
Block a user