Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
919 lines
39 KiB
JavaScript
919 lines
39 KiB
JavaScript
/* Importing and reading a PDF.
|
||
*
|
||
* The interesting parts are not that a canvas appears: it is that the library
|
||
* stays out of the main bundle until a PDF is opened, that the pages come out at
|
||
* the right proportions, and that a document read once survives without the
|
||
* network — the offline promise is the whole reason the bytes are cached. */
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { inflateSync } from "node:zlib";
|
||
import {
|
||
api,
|
||
captureDownloads,
|
||
check,
|
||
clearDownloads,
|
||
createNotebook,
|
||
downloads,
|
||
lastDownloadName,
|
||
launch,
|
||
openAddMenu,
|
||
report,
|
||
search,
|
||
withFreshUser,
|
||
} from "./lib.mjs";
|
||
|
||
const PDF_PATH = "/tmp/e2e-test.pdf";
|
||
|
||
/**
|
||
* The text inside a PDF's content streams.
|
||
*
|
||
* pdf-lib flate-compresses what it writes, so searching the raw bytes for a word
|
||
* finds nothing whether it is in there or not. Every stream is inflated and the
|
||
* results joined; ones that are not compressed (or not inflatable) are taken as
|
||
* they are.
|
||
*/
|
||
function inflatedText(bytes) {
|
||
const out = [];
|
||
const open = Buffer.from("stream");
|
||
const close = Buffer.from("endstream");
|
||
let at = 0;
|
||
while ((at = bytes.indexOf(open, at)) !== -1) {
|
||
// "endstream" contains "stream"; matching it would start the next block
|
||
// three bytes into a keyword and inflate garbage.
|
||
if (at >= 3 && bytes.subarray(at - 3, at).toString("latin1") === "end") {
|
||
at += open.length;
|
||
continue;
|
||
}
|
||
let start = at + open.length;
|
||
if (bytes[start] === 0x0d) start++;
|
||
if (bytes[start] === 0x0a) start++;
|
||
const end = bytes.indexOf(close, start);
|
||
if (end === -1) break;
|
||
const chunk = bytes.subarray(start, end);
|
||
try {
|
||
out.push(inflateSync(chunk).toString("latin1"));
|
||
} catch {
|
||
out.push(chunk.toString("latin1")); // stored, not compressed
|
||
}
|
||
at = end + close.length;
|
||
}
|
||
/* Text is written as `<48656C6C6F> Tj` — hex, because the font's encoding is
|
||
not necessarily Latin-1. Decoded here so a check can look for a word. */
|
||
return out
|
||
.join("\n")
|
||
.replace(/<([0-9A-Fa-f]{4,})>/g, (whole, hex) =>
|
||
hex.length % 2
|
||
? whole
|
||
: Buffer.from(hex, "hex").toString("latin1")
|
||
);
|
||
}
|
||
|
||
/** A two-page PDF built here, so the suite depends on no fixture file. */
|
||
function writeTestPdf() {
|
||
const objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>",
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
|
||
null, // stream, filled below
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
|
||
null,
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||
];
|
||
const s1 = "BT /F1 24 Tf 72 760 Td (Seite eins Zebrastreifen) Tj ET";
|
||
const s2 = "BT /F1 24 Tf 72 520 Td (Seite zwei Dichtung) Tj ET";
|
||
objs[3] = `<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`;
|
||
objs[5] = `<< /Length ${s2.length} >>\nstream\n${s2}\nendstream`;
|
||
|
||
let out = "%PDF-1.4\n";
|
||
const offsets = [];
|
||
objs.forEach((o, i) => {
|
||
offsets.push(out.length);
|
||
out += `${i + 1} 0 obj\n${o}\nendobj\n`;
|
||
});
|
||
const xref = out.length;
|
||
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
|
||
for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`;
|
||
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||
writeFileSync(PDF_PATH, out, "latin1");
|
||
}
|
||
|
||
const browser = await launch();
|
||
let session;
|
||
try {
|
||
writeTestPdf();
|
||
session = await withFreshUser(browser, "pdf");
|
||
const p = session.page;
|
||
|
||
/* The library must not be in the bundle everyone downloads. */
|
||
const scriptsBefore = await p.evaluate(() =>
|
||
performance.getEntriesByType("resource").map((r) => r.name)
|
||
);
|
||
check(
|
||
!scriptsBefore.some((u) => /pdf[.-]/i.test(u)),
|
||
"pdf.js is not loaded before a PDF is opened",
|
||
scriptsBefore.filter((u) => /pdf/i.test(u)).join(", ") || "none"
|
||
);
|
||
|
||
await createNotebook(p, "PDF Buch");
|
||
|
||
/* ── import ── */
|
||
const chooser = p.waitForEvent("filechooser");
|
||
await openAddMenu(p);
|
||
await p.locator('[data-add-option="Upload PDF"]').click();
|
||
await (await chooser).setFiles(PDF_PATH);
|
||
await p.waitForTimeout(4000);
|
||
|
||
check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing");
|
||
const pdfNoteId = (await api(p, "GET", "/notes")).body[0].id;
|
||
check(
|
||
(await p.locator("[data-pdf-position]").innerText()).includes("of 2"),
|
||
"both pages are counted",
|
||
await p.locator("[data-pdf-position]").innerText()
|
||
);
|
||
await p.screenshot({ path: "/out/pdf-imported.png" });
|
||
|
||
/* ── proportions ── */
|
||
const boxes = await p.$$eval("[data-pdf-page]", (els) =>
|
||
els.map((e) => ({ w: e.offsetWidth, h: e.offsetHeight }))
|
||
);
|
||
check(boxes.length === 2, "both pages are laid out", JSON.stringify(boxes));
|
||
// Page one is A4 portrait (595×842), page two the same turned on its side.
|
||
const ratio1 = boxes[0].h / boxes[0].w;
|
||
const ratio2 = boxes[1].h / boxes[1].w;
|
||
check(Math.abs(ratio1 - 842 / 595) < 0.05, "the portrait page keeps its proportions", ratio1.toFixed(2));
|
||
check(Math.abs(ratio2 - 595 / 842) < 0.05, "the landscape page is not stretched to match", ratio2.toFixed(2));
|
||
|
||
/* ── something was actually painted ── */
|
||
const inkedPixels = await p.evaluate(() => {
|
||
const c = document.querySelector("[data-pdf-page] canvas");
|
||
if (!c) return -1;
|
||
const ctx = c.getContext("2d");
|
||
const d = ctx.getImageData(0, 0, c.width, Math.min(c.height, 400)).data;
|
||
let dark = 0;
|
||
for (let i = 0; i < d.length; i += 4) if (d[i] < 200) dark++;
|
||
return dark;
|
||
});
|
||
check(inkedPixels > 50, "the page has actual content drawn on it", `${inkedPixels} dark pixels`);
|
||
|
||
const loadedAfter = await p.evaluate(() => performance.getEntriesByType("resource").map((r) => r.name));
|
||
check(
|
||
loadedAfter.some((u) => /pdf.*\.js|pdf\.worker/i.test(u)),
|
||
"pdf.js was fetched only once it was needed"
|
||
);
|
||
|
||
/* ── selectable text ── */
|
||
const layerText = await p
|
||
.locator('[data-pdf-page="1"] .pdf-text-layer')
|
||
.innerText()
|
||
.catch(() => "");
|
||
check(layerText.includes("Zebrastreifen"), "the page text is laid over the picture", layerText.slice(0, 60));
|
||
|
||
const spanBox = await p.locator('[data-pdf-page="1"] .pdf-text-layer span').first().boundingBox();
|
||
const pageBox = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||
check(
|
||
spanBox && spanBox.width > 4 && spanBox.x > pageBox.x && spanBox.y > pageBox.y,
|
||
"and positioned over the page rather than stacked in a corner",
|
||
JSON.stringify(spanBox && { x: Math.round(spanBox.x - pageBox.x), y: Math.round(spanBox.y - pageBox.y) })
|
||
);
|
||
const invisible = await p
|
||
.locator('[data-pdf-page="1"] .pdf-text-layer span')
|
||
.first()
|
||
.evaluate((e) => getComputedStyle(e).color);
|
||
check(invisible.includes("rgba(0, 0, 0, 0)"), "the overlaid text is transparent", invisible);
|
||
|
||
/* Select it the way a reader would: drag across the line. */
|
||
await p.mouse.move(spanBox.x + 2, spanBox.y + spanBox.height / 2);
|
||
await p.mouse.down();
|
||
await p.mouse.move(spanBox.x + spanBox.width - 2, spanBox.y + spanBox.height / 2, { steps: 10 });
|
||
await p.mouse.up();
|
||
const selected = await p.evaluate(() => String(window.getSelection()));
|
||
check(selected.trim().length > 0, "dragging across the line selects text", JSON.stringify(selected.slice(0, 40)));
|
||
await p.screenshot({ path: "/out/pdf-selection.png" });
|
||
|
||
/* ── the document is findable ── */
|
||
await p.waitForTimeout(2500); // extraction runs after the first page is shown
|
||
await search(p, "Zebrastreifen");
|
||
check(
|
||
(await p.locator("[data-note-row]").count()) === 1,
|
||
"searching a word from inside the PDF finds the note",
|
||
`${await p.locator("[data-note-row]").count()} rows`
|
||
);
|
||
await search(p, "Wortdasnichtvorkommt");
|
||
check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing");
|
||
await search(p, "");
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(3000);
|
||
|
||
/* ── drawing on a page ── */
|
||
const inkOnPage = (n) =>
|
||
p.evaluate((page) => {
|
||
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
|
||
if (!c) return -1;
|
||
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
|
||
let painted = 0;
|
||
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) painted++;
|
||
return painted;
|
||
}, n);
|
||
|
||
/** Draws a line across the page in fractions of its box, then waits for the save. */
|
||
const drawOn = async (pageNo, x1, y1, x2, y2) => {
|
||
const box = await p.locator(`[data-pdf-page="${pageNo}"]`).boundingBox();
|
||
await p.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
|
||
await p.mouse.down();
|
||
await p.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 });
|
||
await p.mouse.up();
|
||
await p.waitForTimeout(1600);
|
||
};
|
||
|
||
check((await inkOnPage(1)) === 0, "no ink before anything is drawn");
|
||
await p.locator("[data-pdf-mode]").click();
|
||
check((await p.locator('[data-pdf-mode="draw"]').count()) === 1, "drawing mode can be switched on");
|
||
|
||
const textEvents = await p
|
||
.locator('[data-pdf-page="1"] .pdf-text-layer')
|
||
.evaluate((e) => getComputedStyle(e).pointerEvents);
|
||
check(
|
||
textEvents === "none",
|
||
"while drawing, the text layer stops taking the pointer so a stroke is not a selection",
|
||
textEvents
|
||
);
|
||
|
||
await drawOn(1, 0.2, 0.25, 0.7, 0.3);
|
||
const inkedFirst = await inkOnPage(1);
|
||
check(inkedFirst > 100, "the stroke appears on the page", `${inkedFirst} painted pixels`);
|
||
check((await inkOnPage(2)) === 0, "and only on the page it was drawn on");
|
||
await p.screenshot({ path: "/out/pdf-drawn.png" });
|
||
|
||
/* Where the stroke sits relative to the page, so zoom can be compared against it. */
|
||
const inkBounds = (n) =>
|
||
p.evaluate((page) => {
|
||
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
|
||
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
|
||
let minX = 1e9, maxX = -1, minY = 1e9, maxY = -1;
|
||
for (let i = 3, px = 0; i < d.length; i += 4, px++) {
|
||
if (d[i] <= 40) continue;
|
||
const x = px % c.width, y = (px / c.width) | 0;
|
||
if (x < minX) minX = x;
|
||
if (x > maxX) maxX = x;
|
||
if (y < minY) minY = y;
|
||
if (y > maxY) maxY = y;
|
||
}
|
||
// As fractions of the canvas, so they can be compared across zoom levels.
|
||
return { x: minX / c.width, y: minY / c.height, w: (maxX - minX) / c.width, h: (maxY - minY) / c.height };
|
||
}, n);
|
||
|
||
const before = await inkBounds(1);
|
||
await p.getByTitle("Zoom in").click();
|
||
await p.waitForTimeout(2500);
|
||
const after = await inkBounds(1);
|
||
const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
|
||
check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
|
||
await p.getByTitle("Zoom out").click();
|
||
await p.waitForTimeout(1500);
|
||
|
||
/* ── hiding it ── */
|
||
await p.locator("[data-pdf-hide-ink]").click();
|
||
await p.waitForTimeout(600);
|
||
check((await inkOnPage(1)) >= 0, "hiding the drawing leaves the document readable");
|
||
const hiddenOpacity = await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity);
|
||
check(hiddenOpacity === "0", "the ink layer is hidden, not erased", hiddenOpacity);
|
||
await p.locator("[data-pdf-hide-ink]").click();
|
||
await p.waitForTimeout(600);
|
||
|
||
/* ── it survives ── */
|
||
await p.reload({ waitUntil: "networkidle" });
|
||
await p.waitForTimeout(2500);
|
||
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||
await p.waitForTimeout(1200);
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(4500);
|
||
const afterReload = await inkOnPage(1);
|
||
check(afterReload > 100, "the stroke survived a reload", `${afterReload} painted pixels`);
|
||
|
||
/* ── it must not be undone by the background poller ── */
|
||
await p.waitForTimeout(9000);
|
||
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
|
||
|
||
/* ── notes beside the document ── */
|
||
check((await p.locator("[data-pdf-notes]").count()) === 0, "the notes panel starts folded away");
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(1200);
|
||
check((await p.locator("[data-pdf-notes]").count()) === 1, "it opens beside the document");
|
||
check((await p.locator("[data-pdf-page]").count()) > 0, "and the document is still there next to it");
|
||
|
||
const noteText = "Wichtig auf Seite eins: Randbemerkung.";
|
||
await p.locator("[data-pdf-notes] .ProseMirror").click();
|
||
await p.keyboard.type(noteText);
|
||
await p.waitForTimeout(2200);
|
||
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(600);
|
||
check((await p.locator("[data-pdf-notes]").count()) === 0, "and folds away again without losing anything");
|
||
|
||
/* Written notes belong to the note, so they survive like any other text. */
|
||
await p.reload({ waitUntil: "networkidle" });
|
||
await p.waitForTimeout(2500);
|
||
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||
await p.waitForTimeout(1200);
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(4000);
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(1500);
|
||
check(
|
||
(await p.locator("[data-pdf-notes] .ProseMirror").innerText()).includes("Randbemerkung"),
|
||
"the notes survived a reload"
|
||
);
|
||
await p.screenshot({ path: "/out/pdf-with-notes.png" });
|
||
|
||
/* And they are searchable, alongside the document's own text. */
|
||
await search(p, "Randbemerkung");
|
||
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
|
||
await search(p, "");
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(3000);
|
||
|
||
/* ── exporting the document ── */
|
||
await captureDownloads(p);
|
||
const originalBytes = readFileSync(PDF_PATH);
|
||
|
||
const openExport = async () => {
|
||
await p.getByTitle("Export", { exact: true }).click();
|
||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||
await p.waitForTimeout(1200);
|
||
};
|
||
|
||
await openExport();
|
||
check(
|
||
(await p.getByText("What should come out?").count()) > 0,
|
||
"a PDF note is asked what should come out, not offered note formats"
|
||
);
|
||
await p.screenshot({ path: "/out/pdf-export-wizard.png" });
|
||
|
||
/* The untouched original. */
|
||
await clearDownloads(p);
|
||
await p.getByRole("button", { name: /The original PDF/ }).click();
|
||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||
await p.waitForTimeout(900);
|
||
let files = await downloads(p);
|
||
const plain = Buffer.from(files[0].base64, "base64");
|
||
check(files[0].type === "application/pdf", "the original comes out as a PDF", files[0].type);
|
||
check(
|
||
plain.equals(originalBytes),
|
||
"and is byte-identical to the file that was imported",
|
||
`${plain.length} vs ${originalBytes.length} bytes`
|
||
);
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(600);
|
||
|
||
/* The same document with the strokes written in. */
|
||
await openExport();
|
||
await clearDownloads(p);
|
||
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
|
||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||
await p.waitForTimeout(1500);
|
||
files = await downloads(p);
|
||
const stamped = Buffer.from(files[0].base64, "base64");
|
||
|
||
check(stamped.subarray(0, 5).toString() === "%PDF-", "the annotated file is a PDF", stamped.subarray(0, 8).toString());
|
||
check(stamped.length > originalBytes.length, "and carries more than the original did", `${stamped.length} vs ${originalBytes.length}`);
|
||
check(!stamped.equals(originalBytes), "so it is not simply a copy");
|
||
check(/annotated/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
|
||
|
||
/* The stored file must still be the original — drawing on it changes nothing
|
||
on disk, or a clean copy could never be had again. */
|
||
const storedAgain = await p.evaluate(async () => {
|
||
const t = localStorage.getItem("inknote_token");
|
||
const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json();
|
||
const id = notes[0].pdf_attachment_id;
|
||
const r = await fetch("/api/attachments/" + id, { headers: { Authorization: "Bearer " + t } });
|
||
const buf = new Uint8Array(await r.arrayBuffer());
|
||
return Array.from(buf.slice(0, 16));
|
||
});
|
||
check(
|
||
Buffer.from(storedAgain).equals(originalBytes.subarray(0, 16)),
|
||
"the stored file was never modified by drawing on it"
|
||
);
|
||
|
||
/* Bytes added is not the same as ink visible. Render both files and compare
|
||
what is actually on page one. */
|
||
const inkVisible = await p.evaluate(async ([stampedB64, originalB64]) => {
|
||
const toBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||
// The app has already loaded this chunk; importing the same URL reuses it.
|
||
const chunk = performance
|
||
.getEntriesByType("resource")
|
||
.map((r) => r.name)
|
||
.find((n) => /\/assets\/pdf-[A-Za-z0-9_-]+\.js$/.test(n));
|
||
if (!chunk) throw new Error("pdf.js chunk not found among loaded resources");
|
||
const pdfjs = await import(/* @vite-ignore */ chunk);
|
||
const countDark = async (bytes) => {
|
||
const doc = await pdfjs.getDocument({ data: bytes }).promise;
|
||
const page = await doc.getPage(1);
|
||
const vp = page.getViewport({ scale: 1 });
|
||
const c = document.createElement("canvas");
|
||
c.width = Math.round(vp.width);
|
||
c.height = Math.round(vp.height);
|
||
const ctx = c.getContext("2d");
|
||
ctx.fillStyle = "#fff";
|
||
ctx.fillRect(0, 0, c.width, c.height);
|
||
await page.render({ canvasContext: ctx, viewport: vp, canvas: c }).promise;
|
||
const d = ctx.getImageData(0, 0, c.width, c.height).data;
|
||
let dark = 0;
|
||
for (let i = 0; i < d.length; i += 4) if (d[i] < 200 || d[i + 1] < 200 || d[i + 2] < 200) dark++;
|
||
return dark;
|
||
};
|
||
return { stamped: await countDark(toBytes(stampedB64)), original: await countDark(toBytes(originalB64)) };
|
||
}, [files[0].base64, originalBytes.toString("base64")]);
|
||
|
||
check(
|
||
inkVisible.stamped > inkVisible.original * 1.2,
|
||
"the strokes are actually visible when the exported file is rendered",
|
||
`${inkVisible.original} → ${inkVisible.stamped} dark pixels`
|
||
);
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(600);
|
||
|
||
/* ── it stays readable offline ── */
|
||
// A reload lands on the dashboard, which has no note list; the notebook has
|
||
// to be opened first before a row exists to click.
|
||
const reopen = async () => {
|
||
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||
await p.waitForTimeout(1200);
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(4000);
|
||
};
|
||
|
||
await p.reload({ waitUntil: "networkidle" });
|
||
await p.waitForTimeout(2500);
|
||
await reopen();
|
||
check((await p.locator("[data-pdf-page]").count()) > 0, "the document reopens after a reload");
|
||
|
||
await p.context().setOffline(true);
|
||
await p.reload({ waitUntil: "domcontentloaded" });
|
||
await p.waitForTimeout(3000);
|
||
await reopen();
|
||
check(
|
||
(await p.locator("[data-pdf-page]").count()) > 0,
|
||
"and still opens with the network switched off"
|
||
);
|
||
await p.screenshot({ path: "/out/pdf-offline.png" });
|
||
await p.context().setOffline(false);
|
||
/* ── notes pinned to a passage ── */
|
||
// The panel beside the document is for notes about the document; this is for
|
||
// a note about the paragraph you were reading.
|
||
await p.locator("[data-pdf-hide-ink]").click().catch(() => {}); // ink out of the way
|
||
await p.waitForTimeout(400);
|
||
await p.locator("[data-pdf-hide-ink]").click().catch(() => {});
|
||
await p.waitForTimeout(400);
|
||
|
||
check(
|
||
(await p.locator("[data-pdf-pin-mode]").count()) === 1,
|
||
"the viewer offers pinning a note to a spot"
|
||
);
|
||
|
||
await p.locator("[data-pdf-pin-mode]").click();
|
||
await p.waitForTimeout(400);
|
||
check(
|
||
(await p.locator("[data-pdf-pin-mode]").getAttribute("data-pdf-pin-mode")) === "on",
|
||
"and says it is waiting for a spot"
|
||
);
|
||
|
||
const page1 = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||
await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3);
|
||
await p.waitForTimeout(800);
|
||
|
||
check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a marker on the spot");
|
||
check(
|
||
(await p.locator("[data-pdf-margin]").count()) > 0,
|
||
"and the note itself belongs in the margin beside the page, not on it"
|
||
);
|
||
const field = p.getByLabel("Pinned note");
|
||
check(await field.isVisible(), "which opens straight into writing");
|
||
|
||
/* The card has to be level with the passage — that is the whole point of a
|
||
margin note, and the reason it is not simply a list under the document. */
|
||
const levels = await p.evaluate(() => {
|
||
const marker = document.querySelector("[data-pin]").getBoundingClientRect();
|
||
const card = document.querySelector("[data-pin-card]").getBoundingClientRect();
|
||
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
|
||
return { gap: Math.abs(marker.top - card.top), pageRight: page.right, cardLeft: card.left };
|
||
});
|
||
check(levels.gap < 40, "the card sits level with its marker", `${Math.round(levels.gap)}px apart`);
|
||
check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it");
|
||
|
||
await field.fill("Drehmoment beachten");
|
||
await p.getByRole("button", { name: "Done" }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
/* It has to be on the passage, not near it — so the position is checked
|
||
against the page rather than merely being non-zero. */
|
||
const placed = await p.evaluate(() => {
|
||
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
|
||
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
|
||
return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height };
|
||
});
|
||
check(
|
||
Math.abs(placed.x - 0.4) < 0.03 && Math.abs(placed.y - 0.3) < 0.03,
|
||
"the pin sits where the page was clicked",
|
||
`${placed.x.toFixed(2)} / ${placed.y.toFixed(2)}`
|
||
);
|
||
|
||
/* Stored in PDF points, so zooming must not move it off its passage — the same
|
||
property the strokes have. */
|
||
await p.getByTitle("Zoom in").click();
|
||
await p.waitForTimeout(2000);
|
||
const zoomed = await p.evaluate(() => {
|
||
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
|
||
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
|
||
return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height };
|
||
});
|
||
check(
|
||
Math.abs(zoomed.x - placed.x) < 0.02 && Math.abs(zoomed.y - placed.y) < 0.02,
|
||
"and stays there when the document is zoomed",
|
||
`${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}`
|
||
);
|
||
await p.getByTitle("Zoom out").click();
|
||
await p.waitForTimeout(1200);
|
||
|
||
/* Searchable: it lives in the page overlay, not in the note body, so nothing
|
||
else would ever index it. */
|
||
await search(p, "Drehmoment");
|
||
check(
|
||
(await p.locator("[data-note-row]").count()) === 1,
|
||
"a pinned note can be searched for",
|
||
String(await p.locator("[data-note-row]").count())
|
||
);
|
||
await search(p, "");
|
||
|
||
/* It belongs to the note, so it has to come back from the server. */
|
||
await p.reload({ waitUntil: "networkidle" });
|
||
await p.waitForTimeout(2500);
|
||
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||
await p.waitForTimeout(1200);
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(4000);
|
||
check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload");
|
||
|
||
check(
|
||
(await p.locator("[data-pin-card]").innerText()).includes("Drehmoment"),
|
||
"with the text showing in the margin without being opened first",
|
||
await p.locator("[data-pin-card]").innerText()
|
||
);
|
||
|
||
await p.locator("[data-pin]").click();
|
||
await p.waitForTimeout(600);
|
||
check(
|
||
(await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten",
|
||
"and the same text when opened for editing"
|
||
);
|
||
|
||
/* Clearing a page's ink must not take its pinned notes: same page, different
|
||
thing. */
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(400);
|
||
await p.locator("[data-pdf-mode]").click();
|
||
await p.waitForTimeout(500);
|
||
await p.getByTitle("Clear page").click();
|
||
await p.waitForTimeout(2000);
|
||
check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note");
|
||
await p.locator("[data-pdf-mode]").click();
|
||
await p.waitForTimeout(500);
|
||
|
||
await p.locator("[data-pin]").click();
|
||
await p.waitForTimeout(600);
|
||
await p.locator("[data-pin-remove]").click();
|
||
await p.waitForTimeout(2000);
|
||
check((await p.locator("[data-pin]").count()) === 0, "a pinned note can be removed again");
|
||
|
||
/* An export that dropped the reader's own words would lose work as surely as
|
||
one that dropped the drawings. Written back in before the pin is removed. */
|
||
await p.locator("[data-pdf-pin-mode]").click();
|
||
await p.waitForTimeout(400);
|
||
const page1b = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||
await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7);
|
||
await p.waitForTimeout(700);
|
||
await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen");
|
||
await p.getByRole("button", { name: "Done" }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
/* The server keeps its own judgement of "is this overlay empty". It counted
|
||
strokes only, so an overlay holding just pinned notes was stored as null and
|
||
every note in it was lost. */
|
||
const overlay = await p.evaluate(async () => {
|
||
const t = localStorage.getItem("inknote_token");
|
||
const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json();
|
||
const d = await (await fetch("/api/notes/" + notes[0].id, { headers: { Authorization: "Bearer " + t } })).json();
|
||
return JSON.stringify(d.drawingStrokes ?? null);
|
||
});
|
||
check(
|
||
overlay.includes("Randbemerkung"),
|
||
"the server stores an overlay that holds only pinned notes",
|
||
overlay.slice(0, 120)
|
||
);
|
||
|
||
await captureDownloads(p);
|
||
await clearDownloads(p);
|
||
await p.getByTitle("Export", { exact: true }).click();
|
||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||
await p.waitForTimeout(800);
|
||
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
|
||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||
await p.waitForTimeout(1500);
|
||
const withPins = Buffer.from((await downloads(p))[0].base64, "base64");
|
||
check(withPins.subarray(0, 5).toString() === "%PDF-", "the export still produces a PDF");
|
||
check(
|
||
inflatedText(withPins).includes("Randbemerkung"),
|
||
"and the pinned note's own words are written into it",
|
||
`${withPins.length} bytes`
|
||
);
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(800);
|
||
|
||
|
||
|
||
/* ── linking a margin note from the notes beside the document ── */
|
||
// A remark in the margin is short by design. When there is more to say about
|
||
// that passage it belongs in the notes — and it has to be possible to say
|
||
// *which* passage.
|
||
await p.locator("[data-pdf-pin-mode]").click();
|
||
await p.waitForTimeout(400);
|
||
const pageL = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||
await p.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2);
|
||
await p.waitForTimeout(800);
|
||
await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen");
|
||
await p.getByRole("button", { name: "Done" }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(1200);
|
||
const panel = p.locator("[data-pdf-notes] .ProseMirror");
|
||
await panel.click();
|
||
await p.keyboard.type("Dazu ausführlicher: ");
|
||
await p.keyboard.type("@");
|
||
await p.waitForTimeout(1200);
|
||
|
||
const suggestions = await p.locator("body").innerText();
|
||
check(
|
||
/Zebrastreifen nachschlagen/.test(suggestions),
|
||
"the margin notes are offered when linking from the notes",
|
||
suggestions.slice(0, 160).replace(/\n/g, " ")
|
||
);
|
||
|
||
await p.keyboard.press("Enter");
|
||
await p.waitForTimeout(2500);
|
||
const linkEl = panel.locator("[data-note-link]").first();
|
||
check((await linkEl.count()) === 1, "one can be inserted as a link");
|
||
check(
|
||
(await linkEl.getAttribute("data-kind")) === "pin",
|
||
"recorded as a link to a spot, not to a note",
|
||
await linkEl.getAttribute("data-kind")
|
||
);
|
||
check(
|
||
(await linkEl.innerText()).includes("p. 1"),
|
||
"labelled with the page it points at",
|
||
await linkEl.innerText()
|
||
);
|
||
|
||
/* The link must not reach the backlink table: its target is a marker in this
|
||
note's own overlay, and no note has that id. */
|
||
const backAfter = await api(p, "GET", `/notes/${pdfNoteId}/backlinks`);
|
||
check(
|
||
Array.isArray(backAfter.body) && backAfter.body.length === 0,
|
||
"and is not reported as a link to a note",
|
||
JSON.stringify(backAfter.body).slice(0, 100)
|
||
);
|
||
|
||
/* Following it goes to the passage. */
|
||
await panel.locator("[data-note-link]").first().click();
|
||
await p.waitForTimeout(2500);
|
||
check(
|
||
(await p.getByLabel("Pinned note").inputValue()) === "Zebrastreifen nachschlagen",
|
||
"following the link opens the margin note it points at",
|
||
await p.getByLabel("Pinned note").inputValue()
|
||
);
|
||
|
||
// With the notes panel open there is no room for a margin column beside the
|
||
// page, so the remark opens in the sheet instead. Closing the panel gives the
|
||
// column back — and the same remark is still the open one.
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(1500);
|
||
const openCard = p.locator("[data-pin-card]").filter({ has: p.getByLabel("Pinned note") });
|
||
check((await openCard.count()) === 1, "and it is the card that is open in the margin");
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(600);
|
||
|
||
/* ── the two visibility switches, together at the end of the bar ── */
|
||
// Drawings and margin notes are two halves of one question — how much of your
|
||
// own work do you want to see — and they used to sit at opposite ends of the
|
||
// toolbar, each beside the tool that made them.
|
||
// Each switch appears only when there is something of its kind to hide, so a
|
||
// stroke goes back on the page first — the pair is the point here.
|
||
await p.locator("[data-pdf-mode]").click();
|
||
await p.waitForTimeout(600);
|
||
const inkPage = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||
await p.mouse.move(inkPage.x + 60, inkPage.y + 300);
|
||
await p.mouse.down();
|
||
await p.mouse.move(inkPage.x + 160, inkPage.y + 330, { steps: 8 });
|
||
await p.mouse.up();
|
||
await p.waitForTimeout(2000);
|
||
await p.locator("[data-pdf-mode]").click();
|
||
await p.waitForTimeout(700);
|
||
|
||
const barOrder = await p.evaluate(() => {
|
||
const bar = document.querySelector("[data-pdf-mode]")?.parentElement;
|
||
if (!bar) return null;
|
||
const at = (sel) => {
|
||
const el = document.querySelector(sel);
|
||
return el ? el.getBoundingClientRect().left : null;
|
||
};
|
||
return {
|
||
draw: at("[data-pdf-mode]"),
|
||
pin: at("[data-pdf-pin-mode]"),
|
||
ink: at("[data-pdf-hide-ink]"),
|
||
pins: at("[data-pdf-show-pins]"),
|
||
barRight: bar.getBoundingClientRect().right,
|
||
};
|
||
});
|
||
check(
|
||
barOrder.ink > barOrder.pin && barOrder.pins > barOrder.ink,
|
||
"both switches sit past the tools, side by side",
|
||
JSON.stringify(barOrder)
|
||
);
|
||
check(
|
||
barOrder.barRight - barOrder.pins < 160,
|
||
"at the right-hand end of the bar",
|
||
`${Math.round(barOrder.barRight - barOrder.pins)}px from the edge`
|
||
);
|
||
|
||
/* The drawing tools belong to the button that turns them on. */
|
||
await p.locator("[data-pdf-mode]").click();
|
||
await p.waitForTimeout(700);
|
||
const toolAt = await p.evaluate(() => ({
|
||
draw: document.querySelector("[data-pdf-mode]").getBoundingClientRect().right,
|
||
firstTool: document.querySelector('[data-pdf-tool="pen"]').getBoundingClientRect().left,
|
||
pin: document.querySelector("[data-pdf-pin-mode]").getBoundingClientRect().left,
|
||
}));
|
||
check(
|
||
toolAt.firstTool > toolAt.draw && toolAt.firstTool < toolAt.pin,
|
||
"and the drawing tools appear right beside Zeichnen, not past everything else",
|
||
JSON.stringify(toolAt)
|
||
);
|
||
await p.locator("[data-pdf-mode]").click();
|
||
await p.waitForTimeout(700);
|
||
|
||
/* Each switch hides its own thing and nothing else. They shared a condition
|
||
once, so hiding the drawing took the markers and their column with it —
|
||
one switch turning off both of the things the pair exists to separate. */
|
||
const drawingShows = async () =>
|
||
(await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity)) !== "0";
|
||
const marginNotesShow = async () => (await p.locator("[data-pin]").count()) > 0;
|
||
|
||
check((await drawingShows()) && (await marginNotesShow()), "both are showing to begin with");
|
||
|
||
await p.locator("[data-pdf-hide-ink]").click();
|
||
await p.waitForTimeout(900);
|
||
check(!(await drawingShows()), "hiding the drawing hides the drawing");
|
||
check(await marginNotesShow(), "and leaves the margin notes alone");
|
||
check(
|
||
(await p.locator("[data-pdf-margin]").count()) > 0,
|
||
"including their column",
|
||
String(await p.locator("[data-pdf-margin]").count())
|
||
);
|
||
await p.locator("[data-pdf-hide-ink]").click();
|
||
await p.waitForTimeout(900);
|
||
|
||
await p.locator("[data-pdf-show-pins]").click();
|
||
await p.waitForTimeout(900);
|
||
check(!(await marginNotesShow()), "hiding the margin notes hides the margin notes");
|
||
check(await drawingShows(), "and leaves the drawing alone");
|
||
await p.locator("[data-pdf-show-pins]").click();
|
||
await p.waitForTimeout(900);
|
||
|
||
check((await p.locator("[data-pdf-show-pins]").count()) === 1, "the margin notes can be hidden");
|
||
await p.locator("[data-pdf-show-pins]").click();
|
||
await p.waitForTimeout(1200);
|
||
check((await p.locator("[data-pin]").count()) === 0, "hiding takes the markers off the page");
|
||
check((await p.locator("[data-pdf-margin]").count()) === 0, "and the column with them");
|
||
|
||
const pageWide = (await p.locator('[data-pdf-page="2"]').boundingBox()).width;
|
||
await p.locator("[data-pdf-show-pins]").click();
|
||
await p.waitForTimeout(1200);
|
||
check((await p.locator("[data-pin]").count()) > 0, "showing brings them back");
|
||
check(
|
||
(await p.locator('[data-pdf-page="2"]').boundingBox()).width < pageWide,
|
||
"and the page gives the column its width again"
|
||
);
|
||
|
||
/* ── on a phone there is no margin to write in ── */
|
||
// A page narrow enough to read leaves no white space beside it, so taking a
|
||
// column for notes would make the document itself unreadable. The note comes
|
||
// up from the bottom instead — over the page, but only while it is open.
|
||
await p.locator("[data-pdf-pin-mode]").click();
|
||
await p.waitForTimeout(400);
|
||
const pageM = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||
await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4);
|
||
await p.waitForTimeout(800);
|
||
await p.getByLabel("Pinned note").fill("Am Rand notiert");
|
||
await p.getByRole("button", { name: "Done" }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
const wideMargins = await p.locator("[data-pdf-margin]").count();
|
||
check(wideMargins > 0, "on a wide screen the note is in the margin", String(wideMargins));
|
||
const pageWithMargin = (await p.locator('[data-pdf-page="1"]').boundingBox()).width;
|
||
|
||
await p.setViewportSize({ width: 420, height: 820 });
|
||
await p.waitForTimeout(2000);
|
||
// Below the phone breakpoint the list and the editor take turns, so the note
|
||
// has to be opened again to be looking at the document at all.
|
||
// Shrinking a desktop window past the breakpoint leaves the sidebar and its
|
||
// backdrop up, which no real phone ever sees. Dismissed here so the rest of
|
||
// the section is testing the document and not an overlay.
|
||
if ((await p.locator("[data-pdf-page]").count()) === 0) {
|
||
// By title: the notebook now holds the note that grew out of a remark too,
|
||
// and the first row is not necessarily the document.
|
||
await p.evaluate(() => {
|
||
const rows = [...document.querySelectorAll("[data-note-row]")];
|
||
(rows.find((r) => r.textContent.includes("e2e-test")) || rows[0])?.click();
|
||
});
|
||
await p.waitForTimeout(4500);
|
||
}
|
||
await p.mouse.click(410, 800);
|
||
await p.waitForTimeout(800);
|
||
check(
|
||
(await p.locator("[data-pdf-margin]").count()) === 0,
|
||
"on a narrow one the margin is gone",
|
||
String(await p.locator("[data-pdf-margin]").count())
|
||
);
|
||
check((await p.locator("[data-pin]").count()) > 0, "but the markers are still on their spots");
|
||
|
||
// The widest page should fill what the screen has, rather than share it with
|
||
// a column. (Page 1 is portrait in a document whose widest page is landscape,
|
||
// so it is legitimately narrower — page 2 is the one that sets the scale.)
|
||
void pageWithMargin;
|
||
const widest = (await p.locator('[data-pdf-page="2"]').boundingBox()).width;
|
||
check(
|
||
widest > 420 - 60,
|
||
"and the page gets the width the column was taking",
|
||
`${Math.round(widest)}px of 420`
|
||
);
|
||
|
||
/* Reserving a column made the viewer wider, which made there be room for the
|
||
column — the page ended up at 254px inside a 420px phone while the layout
|
||
insisted it had 611px. A flex item is as wide as its content unless told
|
||
otherwise. */
|
||
const fits = await p.evaluate(() => {
|
||
let el = document.querySelector("[data-pdf-page]")?.parentElement;
|
||
while (el && getComputedStyle(el).overflow !== "auto") el = el.parentElement;
|
||
return { scroller: el?.clientWidth ?? 0, window: window.innerWidth };
|
||
});
|
||
check(
|
||
fits.scroller <= fits.window,
|
||
"and the document area does not push itself past the window",
|
||
`${fits.scroller} vs ${fits.window}`
|
||
);
|
||
|
||
const marker = p.locator("[data-pin]").first();
|
||
const markerText = await marker.getAttribute("title");
|
||
await marker.scrollIntoViewIfNeeded();
|
||
await marker.click();
|
||
await p.waitForTimeout(800);
|
||
check((await p.locator("[data-pin-sheet]").count()) === 1, "tapping the marker opens a sheet");
|
||
check(
|
||
(await p.getByLabel("Pinned note").inputValue()) === markerText,
|
||
"with the note that was written on the desktop",
|
||
markerText
|
||
);
|
||
|
||
const sheet = await p.locator("[data-pin-sheet]").boundingBox();
|
||
check(
|
||
sheet.y + sheet.height > 700,
|
||
"at the bottom, where a thumb reaches",
|
||
`top ${Math.round(sheet.y)} of 820`
|
||
);
|
||
|
||
await p.getByRole("button", { name: "Done" }).click();
|
||
await p.waitForTimeout(800);
|
||
check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again");
|
||
|
||
await p.setViewportSize({ width: 1400, height: 950 });
|
||
await p.waitForTimeout(2000);
|
||
|
||
/* ── the notes panel lives in the same toolbar now ── */
|
||
check(
|
||
(await p.locator("[data-pdf-toggle-notes]").count()) === 1,
|
||
"the notes control sits with the document's other controls"
|
||
);
|
||
const sameBar = await p.evaluate(() => {
|
||
const notes = document.querySelector("[data-pdf-toggle-notes]");
|
||
const mode = document.querySelector("[data-pdf-mode]");
|
||
return notes && mode ? notes.parentElement === mode.parentElement : false;
|
||
});
|
||
check(sameBar, "in the one toolbar, not in a row of its own above it");
|
||
|
||
} catch (e) {
|
||
check(false, "suite ran to the end", e.message);
|
||
} finally {
|
||
await session?.cleanup();
|
||
const code = report();
|
||
await browser.close();
|
||
process.exit(code);
|
||
}
|