Four things that were built for a mouse on a desk and quietly did nothing useful on a phone. The last one is the serious one. **A note made offline lost everything written into it.** Its create reached the server, the server named it something else, and every edit still queued behind it went on addressing the name only that device ever knew — 404, five retries, thrown away. The note arrived empty and untitled, with nothing to say that anything had been lost. The database was repointed at the new id all along; the queue was not. It is now, by replacing the id across the whole queued payload rather than in a list of named fields, because a list of fields is a list to forget to add to. The same held for a notebook made offline and everything filed into it. **The order of notes could not be changed on a phone**, because the list went through the HTML5 drag API, which mobile browsers never fire from touch. Pointer events instead, which mouse, pen and finger all produce. A mouse still drags a row from anywhere; a finger drags from a grip, because pressing the row has to go on scrolling and holding it has to go on opening the menu. The row under the finger is found with elementFromPoint — a finger raises no pointerover on anything it did not press. **"Move to..." opened off the side of the screen**, and could not be opened at all without a hover. It is a tap now, unfolding in place where there is no room beside the menu and flipping sides where there is. The menu itself is placed by measuring rather than by assuming it is 220px wide, its rows are tall enough to hit with a thumb, and Escape closes it. **Installing said nothing when it could not be done.** The entry appeared only when the browser had offered a prompt, so where installing was impossible there was no button and no reason. It is always there now and names the cause — most usefully that a browser will only install a site served over HTTPS, which is also why the service worker would not have registered, so one cause was showing up as two mysteries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
528 lines
22 KiB
JavaScript
528 lines
22 KiB
JavaScript
/* Sorting, importing, and the two dialogs that replaced a guess and a prompt.
|
||
*
|
||
* The load-bearing claim here is the one about sorting: an arrangement the user
|
||
* made by hand has to survive being looked away from. Searching imposes its own
|
||
* order, and the danger is that going back to "own order" shows something
|
||
* nobody arranged — because the reorder was computed against a filtered list.
|
||
* That is checked by arranging, searching, and coming back.
|
||
*/
|
||
import {
|
||
api,
|
||
check,
|
||
createNote,
|
||
createNotebook,
|
||
launch,
|
||
openAddMenu,
|
||
report,
|
||
search,
|
||
setTitle,
|
||
typeInEditor,
|
||
withFreshUser,
|
||
} from "./lib.mjs";
|
||
|
||
const browser = await launch();
|
||
let session;
|
||
try {
|
||
session = await withFreshUser(browser, "organise");
|
||
const p = session.page;
|
||
|
||
const titles = () =>
|
||
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) =>
|
||
els.map((e) => e.textContent.trim())
|
||
);
|
||
const sortBy = async (mode) => {
|
||
await p.locator("[data-sort-button]").click();
|
||
await p.waitForTimeout(300);
|
||
await p.locator(`[data-sort-option="${mode}"]`).click();
|
||
await p.waitForTimeout(900);
|
||
};
|
||
|
||
/* ── three notes, made in a known order ── */
|
||
await createNotebook(p, "Ordnung");
|
||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||
await p.waitForTimeout(700);
|
||
|
||
for (const name of ["Alpha", "Beta", "Gamma"]) {
|
||
await createNote(p);
|
||
await setTitle(p, name);
|
||
await typeInEditor(p, `Inhalt von ${name}. `);
|
||
await p.waitForTimeout(700);
|
||
}
|
||
await p.waitForTimeout(1200);
|
||
|
||
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
|
||
|
||
/* ── the built-in orders ── */
|
||
await sortBy("updated");
|
||
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
|
||
|
||
await sortBy("oldest");
|
||
check((await titles())[0] === "Alpha", "oldest first reverses it", (await titles()).join(", "));
|
||
|
||
await sortBy("title");
|
||
check((await titles()).join(",") === "Alpha,Beta,Gamma", "A–Z sorts by title", (await titles()).join(", "));
|
||
|
||
/* ── it is remembered, not just applied ── */
|
||
await p.reload({ waitUntil: "networkidle" });
|
||
await p.waitForTimeout(2500);
|
||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||
await p.waitForTimeout(1500);
|
||
check(
|
||
(await titles()).join(",") === "Alpha,Beta,Gamma",
|
||
"and it survives a reload, because it belongs to the notebook",
|
||
(await titles()).join(", ")
|
||
);
|
||
|
||
const notebooks = await api(p, "GET", "/notebooks");
|
||
check(notebooks.body[0].sort_mode === "title", "the notebook itself carries the choice", String(notebooks.body[0].sort_mode));
|
||
|
||
/* ── the user's own order ── */
|
||
await sortBy("manual");
|
||
const beforeDrag = await titles();
|
||
check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", "));
|
||
|
||
// Drag the last note onto the first. The moves are stepped: a single jump can
|
||
// land before the drag has started, and the drop indicator shifts the rows as
|
||
// it appears, so the pointer has to arrive gradually.
|
||
const drag = async (fromLoc, toLoc) => {
|
||
const from = await fromLoc.boundingBox();
|
||
const to = await toLoc.boundingBox();
|
||
await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||
await p.mouse.down();
|
||
await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 12, { steps: 4 });
|
||
await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
|
||
await p.waitForTimeout(300);
|
||
await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2 + 2, { steps: 4 });
|
||
await p.mouse.up();
|
||
await p.waitForTimeout(2000);
|
||
};
|
||
|
||
const rows = p.locator("[data-note-row]");
|
||
await drag(rows.nth(2), rows.nth(0));
|
||
|
||
const arranged = await titles();
|
||
check(
|
||
arranged[0] === beforeDrag[2],
|
||
"dragging a note to the top puts it there",
|
||
`${beforeDrag.join(",")} → ${arranged.join(",")}`
|
||
);
|
||
|
||
/* The search box is simply there, with nothing to open first. A magnifying
|
||
* glass costs a click and having to remember that searching is possible; the
|
||
* box is one line and says for itself what it does. */
|
||
check(
|
||
(await p.locator('input[aria-label="Search in this notebook"]').count()) === 1,
|
||
"the search box is there without opening anything"
|
||
);
|
||
check(
|
||
(await p.locator("[data-search-toggle]").count()) === 0,
|
||
"and the button that used to open it is gone"
|
||
);
|
||
|
||
/* The point of the whole feature. Measured against the arrangement as it
|
||
stands now: the rows have been moved about since it was first made. */
|
||
const arrangedNow = await titles();
|
||
await search(p, "Beta");
|
||
const searched = await titles();
|
||
check(searched.length < 3, "searching narrows the list", searched.join(", "));
|
||
|
||
await search(p, "");
|
||
check(
|
||
(await titles()).join(",") === arrangedNow.join(","),
|
||
"and clearing the search shows exactly the arrangement again",
|
||
`${arrangedNow.join(",")} vs ${(await titles()).join(",")}`
|
||
);
|
||
|
||
/* Reordering must not look like editing: a dragged note keeps its place in
|
||
"last changed", or the two orders would fight each other. */
|
||
await sortBy("updated");
|
||
check(
|
||
(await titles())[0] === "Gamma",
|
||
"a dragged note did not become the most recently changed one",
|
||
(await titles()).join(", ")
|
||
);
|
||
await sortBy("manual");
|
||
await p.waitForTimeout(800);
|
||
|
||
/* ── the drop indicator ── */
|
||
// It used to be a border on the row, set beside a `border` shorthand — which
|
||
// left a permanent 2px black line behind once it was cleared.
|
||
const strayLines = await p.locator("[data-drop-line]").count();
|
||
check(strayLines === 0, "no drop indicator is showing while nothing is being dragged", String(strayLines));
|
||
|
||
const borders = await p
|
||
.locator("[data-note-row]")
|
||
.evaluateAll((rows) => rows.map((r) => getComputedStyle(r).borderTopWidth));
|
||
check(
|
||
borders.every((b) => b === "1px"),
|
||
"and no row has grown a stray top border",
|
||
borders.join(", ")
|
||
);
|
||
|
||
/* ── separators ── */
|
||
await p.locator("[data-add-separator]").click();
|
||
await p.waitForTimeout(1800);
|
||
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
|
||
|
||
/* Renaming is right-click, like removing. A left-click on the label used to
|
||
start an edit, so the row could not be touched without falling into one. */
|
||
const label = p.getByLabel("Separator label");
|
||
check((await label.count()) === 0, "clicking a separator does not drop straight into renaming");
|
||
|
||
await p.locator("[data-separator]").click({ button: "right" });
|
||
await p.waitForTimeout(600);
|
||
await p.getByText("Rename", { exact: true }).click();
|
||
await p.waitForTimeout(500);
|
||
await label.fill("Wichtig");
|
||
await label.press("Enter");
|
||
await p.waitForTimeout(1800);
|
||
check(
|
||
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
|
||
"and it can be named from the same menu",
|
||
await p.locator("[data-separator]").innerText()
|
||
);
|
||
|
||
// It belongs to the notebook, so it has to survive a reload like the order does.
|
||
await p.reload({ waitUntil: "networkidle" });
|
||
await p.waitForTimeout(2500);
|
||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||
await p.waitForTimeout(1800);
|
||
check(
|
||
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
|
||
"a separator survives a reload, because it belongs to the notebook"
|
||
);
|
||
|
||
const withSeparator = await titles();
|
||
check(
|
||
withSeparator.join(",") === arranged.join(","),
|
||
"and adding one did not disturb the arrangement",
|
||
`${arranged.join(",")} vs ${withSeparator.join(",")}`
|
||
);
|
||
|
||
// Separators only mean something in a hand-made order; in "newest first" the
|
||
// notes would slide past the line on every edit.
|
||
await sortBy("updated");
|
||
check((await p.locator("[data-separator]").count()) === 0, "it is hidden in the other orders");
|
||
await sortBy("manual");
|
||
await p.waitForTimeout(800);
|
||
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
|
||
|
||
/* Removing is a right-click behind a confirmation. The × used to sit a few
|
||
pixels from the label you click to rename, so tidying the list and
|
||
destroying part of it were neighbouring gestures. */
|
||
check(
|
||
(await p.locator('[data-separator] button[aria-label="Remove separator"]').count()) === 0,
|
||
"there is no delete button sitting next to the rename target"
|
||
);
|
||
|
||
await p.locator("[data-separator]").click({ button: "right" });
|
||
await p.waitForTimeout(600);
|
||
await p.getByText("Remove separator").click();
|
||
await p.waitForTimeout(600);
|
||
check(
|
||
/notes stay/i.test(await p.locator("body").innerText()),
|
||
"right-clicking it asks first, and says the notes are not affected"
|
||
);
|
||
|
||
await p.getByRole("button", { name: "Remove" }).click();
|
||
await p.waitForTimeout(2000);
|
||
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
|
||
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
|
||
|
||
/* ── exporting a note and importing it back ── */
|
||
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
|
||
await p.waitForTimeout(1200);
|
||
await p.locator(".ProseMirror").click();
|
||
await p.keyboard.press("Control+End");
|
||
await p.keyboard.press("Enter");
|
||
// Through the toolbar rather than by typing "- [x] ": inside a bullet item
|
||
// that sequence stays literal text, which is a quirk of the editor's input
|
||
// rules and not what this suite is about.
|
||
await p.getByTitle("Task list").click();
|
||
await p.waitForTimeout(400);
|
||
await p.keyboard.type("erledigt");
|
||
await p.waitForTimeout(600);
|
||
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
|
||
await p.waitForTimeout(2000);
|
||
|
||
const exported = await p.evaluate(async () => {
|
||
const captured = [];
|
||
const realCreate = URL.createObjectURL.bind(URL);
|
||
URL.createObjectURL = (blob) => {
|
||
captured.push(blob);
|
||
return realCreate(blob);
|
||
};
|
||
window.__captured = captured;
|
||
HTMLAnchorElement.prototype.click = function () {};
|
||
return true;
|
||
});
|
||
check(exported, "downloads can be captured");
|
||
|
||
await p.getByTitle("Export").click();
|
||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||
await p.waitForTimeout(600);
|
||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
const markdown = await p.evaluate(async () => {
|
||
const blob = window.__captured?.[0];
|
||
return blob ? await blob.text() : "";
|
||
});
|
||
check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60)));
|
||
check(markdown.startsWith("# Beta"), "and the title as a heading");
|
||
|
||
await p.locator("[data-export-dialog] button").filter({ hasText: /Close|Done/ }).first().click().catch(() => {});
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(600);
|
||
|
||
/* Import it back as a second note. A real file chooser cannot be driven, so
|
||
the file is handed to the input directly — the code under test is what
|
||
happens after a file is chosen. */
|
||
const before = (await api(p, "GET", "/notes")).body.length;
|
||
await p.evaluate((md) => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
document.body.appendChild(input);
|
||
const dt = new DataTransfer();
|
||
dt.items.add(new File([md], "Beta.md", { type: "text/markdown" }));
|
||
input.files = dt.files;
|
||
window.__importInput = input;
|
||
}, markdown);
|
||
|
||
const chooser = p.waitForEvent("filechooser");
|
||
await openAddMenu(p);
|
||
await p.locator('[data-add-option="Import Markdown"]').click();
|
||
const fc = await chooser;
|
||
await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) });
|
||
await p.waitForTimeout(4000);
|
||
|
||
const importedText = await p.locator("body").innerText();
|
||
check(/imported/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
|
||
await p.getByRole("button", { name: /Done/ }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
const after = (await api(p, "GET", "/notes")).body.length;
|
||
check(after === before + 1, "one note more than before", `${before} → ${after}`);
|
||
|
||
const importedNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Beta" && n.id);
|
||
check(!!importedNote, "the imported note kept its title from the heading");
|
||
|
||
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).last().click();
|
||
await p.waitForTimeout(1800);
|
||
const boxes = await p.locator('.ProseMirror input[type="checkbox"]').count();
|
||
check(boxes > 0, "the task box came back as a real checkbox, not as text", String(boxes));
|
||
const checked = await p.locator('.ProseMirror input[type="checkbox"]:checked').count();
|
||
check(checked > 0, "and it came back ticked", String(checked));
|
||
|
||
/* ── the link dialog, which used to be a browser prompt ── */
|
||
await p.locator(".ProseMirror").click();
|
||
await p.keyboard.press("Control+End");
|
||
await p.keyboard.type("Zielwort");
|
||
await p.keyboard.down("Shift");
|
||
for (let i = 0; i < 8; i++) await p.keyboard.press("ArrowLeft");
|
||
await p.keyboard.up("Shift");
|
||
await p.getByTitle("Web link").click();
|
||
await p.waitForTimeout(600);
|
||
|
||
const field = p.getByLabel("Link address");
|
||
check(await field.isVisible(), "the link dialog is a real dialog in the page");
|
||
|
||
await field.fill("javascript:alert(1)");
|
||
await p.getByRole("button", { name: /^Insert$/ }).click();
|
||
await p.waitForTimeout(400);
|
||
check(
|
||
await field.isVisible(),
|
||
"a javascript: address is refused rather than inserted"
|
||
);
|
||
|
||
await field.fill("example.com");
|
||
await p.getByRole("button", { name: /^Insert$/ }).click();
|
||
await p.waitForTimeout(1800);
|
||
const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href");
|
||
check(href === "https://example.com", "a bare domain is taken as https", String(href));
|
||
|
||
/* ── which notebook, when there is no notebook ── */
|
||
await createNotebook(p, "Zweites Buch");
|
||
await p.waitForTimeout(800);
|
||
await p.locator("button").filter({ hasText: "All Notes" }).first().click();
|
||
await p.waitForTimeout(900);
|
||
|
||
// "All Notes" is a question asked of every notebook, not a place — there is
|
||
// nothing for a hand-made arrangement to belong to.
|
||
await p.locator("[data-sort-button]").click();
|
||
await p.waitForTimeout(400);
|
||
check(
|
||
(await p.locator('[data-sort-option="manual"]').count()) === 0,
|
||
"no hand-made order is offered outside a notebook"
|
||
);
|
||
check(
|
||
(await p.locator('[data-sort-option="updated"]').count()) === 1,
|
||
"but the derived orders still are"
|
||
);
|
||
await p.keyboard.press("Escape");
|
||
await p.locator("h2").first().click();
|
||
await p.waitForTimeout(500);
|
||
await openAddMenu(p);
|
||
await p.locator('[data-add-option="New note"]').click();
|
||
await p.waitForTimeout(900);
|
||
|
||
const books0 = (await api(p, "GET", "/notebooks")).body;
|
||
const picker = p.locator("[data-notebook-option]");
|
||
check((await picker.count()) === 2, "creating a note from All Notes asks where it should go", String(await picker.count()));
|
||
|
||
// By name, not by position: two notebooks created without dragging share a
|
||
// sort order, so "the last one" is not a stable way to mean a particular book.
|
||
const target = books0.find((b) => b.name === "Zweites Buch");
|
||
await p.locator(`[data-notebook-option="${target.id}"]`).click();
|
||
await p.getByRole("button", { name: /Continue/ }).click();
|
||
await p.waitForTimeout(2500);
|
||
|
||
// By title, not by position: the list is ordered by a timestamp with one
|
||
// second of resolution, and several notes here share that second.
|
||
const fresh = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Untitled Note");
|
||
check(
|
||
fresh?.notebook_id === target.id,
|
||
"and it lands in the notebook that was chosen, not the first one",
|
||
`${fresh?.notebook_id} vs ${target.id}`
|
||
);
|
||
|
||
/* ── choosing a cover ──
|
||
* Covers are patterns as well as colours now, and there are enough of them
|
||
* that the grid has to scroll: without that the dialog runs past the bottom of
|
||
* a laptop screen and takes the Create button with it. */
|
||
await p.setViewportSize({ width: 1440, height: 800 });
|
||
await p.locator("button").filter({ hasText: "Dashboard" }).first().click();
|
||
await p.waitForTimeout(600);
|
||
await p.getByRole("button", { name: "Add Notebook" }).click();
|
||
await p.waitForTimeout(700);
|
||
const covers = p.locator('[data-cover-option]');
|
||
check((await covers.count()) > 20, "there are patterned covers as well as plain ones", `${await covers.count()} covers`);
|
||
check(
|
||
await p.getByRole("button", { name: "Create", exact: true }).isVisible(),
|
||
"and the dialog still fits a laptop screen, Create and all"
|
||
);
|
||
await p.getByRole("button", { name: "Cancel" }).click();
|
||
await p.waitForTimeout(400);
|
||
/* ── moving a row with a finger ──
|
||
* Last in the suite, because moving rows about would pull the ground from
|
||
* under every check above that names an order.
|
||
*
|
||
* The list used the HTML5 drag API, which mobile browsers never fire from
|
||
* touch: on a phone the arrangement could not be changed at all, and nothing
|
||
* on screen said why. A finger drags from the grip — pressing the row itself
|
||
* has to go on scrolling, and holding it has to go on opening the menu.
|
||
*
|
||
* The pointer events below are synthesised, because Playwright drives a mouse.
|
||
* They still exercise the branch that was missing: pointerType "touch".
|
||
*/
|
||
const touchDrag = async (fromIndex, toIndex) =>
|
||
p.evaluate(
|
||
([from, toIdx]) => {
|
||
const grip = document.querySelectorAll("[data-reorder-grip]")[from];
|
||
const to = document.querySelectorAll("[data-note-row]")[toIdx];
|
||
if (!grip || !to) return "missing";
|
||
const box = to.getBoundingClientRect();
|
||
const fire = (target, type, x, y) =>
|
||
target.dispatchEvent(
|
||
new PointerEvent(type, {
|
||
pointerType: "touch",
|
||
pointerId: 1,
|
||
clientX: x,
|
||
clientY: y,
|
||
bubbles: true,
|
||
cancelable: true,
|
||
})
|
||
);
|
||
const start = grip.getBoundingClientRect();
|
||
fire(grip, "pointerdown", start.x + start.width / 2, start.y + start.height / 2);
|
||
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2 - 20);
|
||
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2);
|
||
fire(window, "pointerup", box.x + box.width / 2, box.y + box.height / 2);
|
||
return "done";
|
||
},
|
||
[fromIndex, toIndex]
|
||
);
|
||
|
||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||
await p.waitForTimeout(1200);
|
||
await sortBy("manual");
|
||
|
||
const beforeTouch = await titles();
|
||
// The last row onto the first, so the order cannot come out the same by luck.
|
||
const rowCount = (await titles()).length;
|
||
check((await p.locator("[data-reorder-grip]").count()) >= rowCount, "every row has a grip to take hold of");
|
||
await touchDrag(rowCount - 1, 0);
|
||
await p.waitForTimeout(2000);
|
||
check(
|
||
(await titles()).join(",") !== beforeTouch.join(","),
|
||
"a row can be moved with a finger, from its grip",
|
||
`${beforeTouch.join(",")} → ${(await titles()).join(",")}`
|
||
);
|
||
|
||
// And pressing the row itself must still scroll rather than drag.
|
||
const beforeBody = await titles();
|
||
await p.evaluate(() => {
|
||
const row = document.querySelector("[data-note-row]");
|
||
const b = row.getBoundingClientRect();
|
||
const fire = (t, type, y) =>
|
||
t.dispatchEvent(
|
||
new PointerEvent(type, { pointerType: "touch", pointerId: 2, clientX: b.x + 40, clientY: y, bubbles: true, cancelable: true })
|
||
);
|
||
fire(row, "pointerdown", b.y + b.height / 2);
|
||
fire(window, "pointermove", b.y + 260);
|
||
fire(window, "pointerup", b.y + 260);
|
||
});
|
||
await p.waitForTimeout(1200);
|
||
check(
|
||
(await titles()).join(",") === beforeBody.join(","),
|
||
"and pressing the row itself does not drag it — that gesture still scrolls",
|
||
`${beforeBody.join(",")} → ${(await titles()).join(",")}`
|
||
);
|
||
|
||
/* ── "Move to..." on a phone ──
|
||
* The submenu was pinned to `left: 100%` and opened only on hover. On a narrow
|
||
* screen the parent menu already sits against the right edge, so the list of
|
||
* notebooks appeared off the side of the display — and with no hover there was
|
||
* no way to open it in the first place. */
|
||
await createNotebook(p, "Anderes Buch");
|
||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||
await p.waitForTimeout(900);
|
||
await p.setViewportSize({ width: 420, height: 780 });
|
||
await p.waitForTimeout(900);
|
||
// Below the breakpoint the sidebar stays open as a full-screen overlay whose
|
||
// backdrop swallows every click. Dismiss it by tapping beside the panel.
|
||
await p.mouse.click(408, 420);
|
||
await p.waitForTimeout(700);
|
||
await p.locator("[data-note-row]").first().click({ button: "right" });
|
||
await p.waitForTimeout(600);
|
||
const menu = p.locator("[data-context-menu]");
|
||
check((await menu.count()) === 1, "the menu opens on a phone-sized screen");
|
||
const inside = async (loc) => {
|
||
const b = await loc.boundingBox();
|
||
return !!b && b.x >= -1 && b.x + b.width <= 421;
|
||
};
|
||
check(await inside(menu), "and stays on the screen", JSON.stringify(await menu.boundingBox()));
|
||
|
||
await p.locator("[data-submenu-toggle]").first().click();
|
||
await p.waitForTimeout(500);
|
||
const sub = p.locator("[data-submenu]");
|
||
check((await sub.count()) === 1, "tapping 'Move to...' opens it — there is no hover on a phone");
|
||
check(
|
||
await inside(sub),
|
||
"and the notebooks it offers are on the screen, not past its edge",
|
||
JSON.stringify(await sub.boundingBox())
|
||
);
|
||
await p.keyboard.press("Escape");
|
||
await p.waitForTimeout(400);
|
||
await p.setViewportSize({ width: 1400, height: 950 });
|
||
await p.waitForTimeout(900);
|
||
|
||
} catch (e) {
|
||
check(false, "suite ran to the end", e.message);
|
||
} finally {
|
||
await session?.cleanup();
|
||
const code = report();
|
||
await browser.close();
|
||
process.exit(code);
|
||
}
|