Ctrl K
ComunidadGuías…

tests/e2e/transactions.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* Transactions page, end to end, in a real browser.
   Run against a dev server:
     demo data:      WALLY_DATA=<dir> DEMO=true uvicorn utils.dev_server:app --port 8802
     empty + 20k:    WALLY_DATA=<fresh dir> uvicorn utils.dev_server:app --port 8802   (then SUITE=perf)
   BASE=http://localhost:8802 [SUITE=demo|perf] node transactions.mjs
   The add sheet is replaced by a stub that records what it was opened with, so this page is tested on its own. */
import { chromium } from "playwright";
import fs from "node:fs";

const BASE = process.env.BASE || "http://localhost:8802";
const SUITE = process.env.SUITE || "demo";
let fails = 0;
const errors = [];
function ok(cond, msg, extra) {
  if (cond) console.log("  ok   " + msg);
  else { fails++; console.log("  FAIL " + msg + (extra !== undefined ? "  → " + JSON.stringify(extra) : "")); }
}
const section = (s) => console.log("\n" + s);
const wait = (p, ms) => p.waitForTimeout(ms || 150);

const ADD_STUB = "export function openAdd(o){(window.__adds=window.__adds||[]).push(o===undefined?null:JSON.parse(JSON.stringify(o)));} export function flushQueue(){}";

let browser;
async function newPage(o) {
  o = o || {};
  const ctx = await browser.newContext({ viewport: { width: o.w || 1440, height: o.h || 900 }, colorScheme: o.theme || "light", serviceWorkers: "block", acceptDownloads: true });
  await ctx.addInitScript(() => { localStorage.setItem("wally.person", "1"); localStorage.removeItem("wally.scope"); });
  await ctx.route("**/js/add.js", (r) => r.fulfill({ contentType: "application/javascript", body: ADD_STUB }));
  const p = await ctx.newPage();
  p.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
  p.on("pageerror", (e) => errors.push("PAGEERROR " + e.message));
  return p;
}
async function go(p, path) { await p.goto(BASE + path, { waitUntil: "networkidle" }); await p.waitForSelector("#tx-main:not([hidden]), #tx-none:not([hidden])"); await wait(p, 120); }
const api = async (method, path, body) => {
  const r = await fetch(BASE + "/api" + path, { method, headers: { "Content-Type": "application/json", "X-Wally-Person": "1" }, body: body === undefined ? undefined : JSON.stringify(body) });
  const text = await r.text();
  return text ? JSON.parse(text) : null;
};
/* Read the browser's own data */
const core = (p, fn, arg) => p.evaluate(async ([src, arg]) => { const c = await import("/js/core.js"); return new Function("c", "arg", "return (" + src + ")(c, arg)")(c, arg); }, [fn.toString(), arg]);
const title = (p) => p.$eval("#tx-title", (e) => e.textContent.trim());
const countN = async (p) => +(await p.$eval("#tx-count", (e) => e.firstChild.textContent)).replace(/[^\d]/g, "");
/* The period is the title, with ‹ › beside it; its menu holds five shortcuts */
const checked = (p) => p.$$eval('#tx-pmenu [aria-checked="true"]', (es) => es.map((e) => e.dataset.pm));
async function pick(p, v) { await p.click(".tx-head .s-title"); await p.click('#tx-pmenu [data-pm="' + v + '"]'); await wait(p); }
async function more(p, act) { await p.click(".tx-more"); await p.click('#tx-more [data-act="' + act + '"]'); await wait(p); }
const ymOf = (y, m) => y + "-" + String(m).padStart(2, "0");
const rows = (p) => p.$$eval("#tx-list .w-row", (es) => es.map((e) => ({ id: e.dataset.id, name: e.querySelector("h6").textContent, sub: e.querySelector("p").textContent, amt: e.querySelector(".amt").textContent, pos: e.querySelector(".amt").classList.contains("pos"), shared: !!e.querySelector(".tx-who .w-av.shared") })));
const selId = (p) => p.$eval('#tx-list [aria-current="true"]', (e) => e.dataset.id).catch(() => null);
const url = (p) => new URL(p.url());
const toastText = (p) => p.$eval("#toast", (e) => (e.classList.contains("on") ? e.querySelector("#toast-t").textContent : ""));
async function typeSearch(p, q) { await p.fill("#tx-q", q); await wait(p, 200); }
async function undo(p) { await p.click("#toast-undo"); await wait(p, 600); }
async function download(p, click) {
  const [dl] = await Promise.all([p.waitForEvent("download"), click()]);
  return { name: dl.suggestedFilename(), text: fs.readFileSync(await dl.path(), "utf8") };
}

/* ============================== demo data ============================== */
async function demo() {
  let p = await newPage();
  await go(p, "/transactions");
  const TODAY = await core(p, (c) => c.TODAY);
  const [ty, tm] = [+TODAY.slice(0, 4), +TODAY.slice(5, 7)];
  const months = await core(p, (c) => c.F.months);
  await wait(p, 200);

  // This year's months need no year in the title
  const T = (m, y) => months[m - 1] + (y === ty ? "" : " " + y);
  section("Month view and arrows");
  ok((await title(p)) === T(tm, ty), "opens on this month", await title(p));
  const dayTotals = await p.evaluate(() => {
    const cents = (s) => Math.round(parseFloat((s || "0").replace(/[^\d.]/g, "")) * 100);
    return [...document.querySelectorAll("#tx-list .w-dayh")].map((head) => {
      let inn = 0, out = 0, nIn = 0, nOut = 0;
      for (let n = head.nextElementSibling; n && n.classList.contains("w-row"); n = n.nextElementSibling) {
        const a = n.querySelector(".amt").firstChild.textContent;
        if (a.startsWith("+")) { inn += cents(a); nIn++; } else { out += cents(a); nOut++; }
      }
      const shown = head.querySelector(".amt"), parts = shown ? shown.textContent.split(" · ") : [], hin = parts.find((x) => x.startsWith("+")), hout = parts.find((x) => !x.startsWith("+"));
      return { day: head.firstChild.textContent, ok: (nIn > 1 ? cents(hin) === inn : hin === undefined) && (nOut > 1 ? cents(hout) === out : hout === undefined) };
    });
  });
  ok(dayTotals.length > 0 && dayTotals.every((x) => x.ok), "a day's header adds up its rows, only where there are two or more to add", dayTotals.filter((x) => !x.ok));
  const inMonth = await core(p, (c) => c.state.tx.filter((x) => x.d.slice(0, 7) === c.TODAY.slice(0, 7)).length);
  ok((await countN(p)) === inMonth, "count is this month's transactions", [await countN(p), inMonth]);
  ok(/In .+Out /.test(await p.$eval("#tx-count", (e) => e.textContent)), "one line says what came in and went out, no chart", await p.$eval("#tx-count", (e) => e.textContent));
  ok(!(await p.$("#tx-strip,#tx-range,#tx-type,#tx-catbtn")) && !!(await p.$("#tx-step")), "‹ › beside the title; no strip, range buttons or filter chips", null);
  ok(await p.$eval('#tx-step [data-act="next"]', (e) => e.disabled), "› is off on the last month");
  await p.click("#tx-list"); await p.keyboard.press("ArrowRight"); await wait(p);
  ok((await title(p)) === T(tm, ty), "→ does not go past this month");
  ok((await checked(p)).join() === "this", "This month is ticked in the period menu", await checked(p));
  await p.click('#tx-step [data-act="prev"]'); await wait(p);
  ok((await title(p)) === T(tm === 1 ? 12 : tm - 1, tm === 1 ? ty - 1 : ty), "‹ goes one month back", await title(p));
  await p.click('#tx-step [data-act="next"]'); await wait(p);
  ok((await title(p)) === T(tm, ty), "› comes forward again");
  await p.keyboard.press("ArrowLeft"); await wait(p);
  const pm = tm === 1 ? 12 : tm - 1, py = tm === 1 ? ty - 1 : ty;
  ok((await title(p)) === T(pm, py), "← previous month", await title(p));
  ok(url(p).searchParams.get("month") === py + "-" + String(pm).padStart(2, "0"), "address has month", p.url());
  const rs = await rows(p);
  const prevMonthAll = await core(p, (c, k) => c.state.tx.filter((x) => x.d.slice(0, 7) === k).length, py + "-" + String(pm).padStart(2, "0"));
  ok(rs.length === prevMonthAll && rs.length > 0, "every transaction of the month is listed", [rs.length, prevMonthAll]);
  await p.click("#tx-list"); await p.keyboard.press("ArrowRight"); await wait(p);
  ok((await title(p)) === T(tm, ty), "→ back to this one");
  const first = await core(p, (c) => c.START);
  await go(p, "/transactions?month=" + first.slice(0, 7));
  await p.click("#tx-list"); await p.keyboard.press("ArrowLeft"); await wait(p);
  ok(url(p).searchParams.get("month") === first.slice(0, 7), "← stops at the first month");

  section("The period menu and keys 1 to 3");
  await go(p, "/transactions");
  await p.click(".tx-head .s-title");
  const menu = await p.$$eval("#tx-pmenu [data-pm]", (b) => b.map((x) => x.textContent));
  ok(menu.join("|") === "This month|Last month|This year|All time|Custom range…", "five shortcuts", menu);
  await p.keyboard.press("Escape");
  for (const [k, label, key] of [["year", String(ty), "2"], ["all", "All time", "3"], ["this", months[tm - 1], "1"]]) {
    await pick(p, k);
    ok((await title(p)).startsWith(label), k + " title", await title(p));
    ok((await checked(p)).join() === k, k + " ticked");
    if (k === "year" || k === "all") ok(url(p).searchParams.get("mode") === k, k + " in the address", p.url());
    await p.click("#tx-list"); await p.keyboard.press(key); await wait(p);
    ok((await checked(p)).join() === k, "key " + key + " picks " + k);
  }
  await pick(p, "all");
  const all = await core(p, (c) => c.state.tx.length);
  ok((await countN(p)) === all, "All shows every transaction", [await countN(p), all]);
  ok(await p.$eval("#tx-list", (e) => e.classList.contains("tx-win")), "over 500 rows: the windowed list");
  ok((await p.$$("#tx-list .w-monh")).length >= 1, "All is grouped by month");
  await pick(p, "year");
  await p.click('#tx-step [data-act="prev"]'); await wait(p);
  ok((await title(p)) === String(ty - 1) && url(p).searchParams.get("year") === String(ty - 1), "‹ in year view goes a year back", [await title(p), p.url()]);
  await pick(p, "last");
  ok(url(p).searchParams.get("month") === ymOf(py, pm) && (await title(p)) === T(pm, py), "Last month opens it", p.url());

  section("Old 3 months and 12 months links");
  await go(p, "/transactions?mode=3m");
  ok((await checked(p)).join() === "this", "mode=3m opens as Month");
  await go(p, "/transactions?mode=12m");
  ok((await checked(p)).join() === "year", "mode=12m opens as Year");

  section("Custom range from the address");
  await go(p, "/transactions?mode=custom&from=" + ty + "-02-03&to=" + ty + "-03-10");
  ok((await checked(p)).join() === "custom", "Custom is ticked");
  const cust = await core(p, (c, y) => c.state.tx.filter((x) => x.d >= y + "-02-03" && x.d <= y + "-03-10").length, ty);
  ok((await countN(p)) === cust, "custom range count", [await countN(p), cust]);
  ok(url(p).searchParams.get("from") === ty + "-02-03", "custom range stays in the address");

  section("Search in every month, and the scope bar");
  await go(p, "/transactions");
  await typeSearch(p, "mercadona");
  ok((await title(p)) === T(tm, ty), "the title keeps the month", await title(p));
  ok(await p.$eval("#tx-scope", (e, m) => !e.hidden && e.textContent === "All months" + m, months[tm - 1]), "a scope bar: All months | this month", await p.$eval("#tx-scope", (e) => e.textContent));
  let rr = await rows(p);
  ok(rr.length > 20 && rr.every((r) => /mercadona/i.test(r.name)), "every month, only Mercadona", rr.length);
  ok((await p.$$("#tx-list .w-monh")).length > 3, "grouped by month");
  const total = await core(p, (c) => c.state.tx.filter((x) => /mercadona/i.test(x.n)).length);
  ok((await countN(p)) === total, "count matches every Mercadona", [await countN(p), total]);
  ok(url(p).searchParams.get("q") === "mercadona" && !url(p).searchParams.get("mode"), "address has the search", p.url());
  await p.click('#tx-scope [data-sc="range"]'); await wait(p);
  ok((await title(p)) === T(tm, ty) && (await p.$eval('#tx-scope [data-sc="range"]', (e) => e.getAttribute("aria-pressed"))) === "true", "the scope bar narrows to the month", await title(p));
  ok((await countN(p)) < total, "fewer after narrowing");
  ok(url(p).searchParams.get("mode") === "month", "narrowed search keeps its range in the address", p.url());
  await p.click('#tx-scope [data-sc="all"]'); await wait(p);
  ok((await countN(p)) === total, "All months widens again");
  await typeSearch(p, "MERCADONA");
  ok((await countN(p)) === total, "search is case-insensitive");

  section("Search language");
  await typeSearch(p, ">50");
  rr = await rows(p);
  const amounts = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).a), rr.map((r) => r.id));
  ok(rr.length && amounts.every((a) => a >= 50), ">50 keeps amounts over 50", rr.length);
  ok(await p.$eval("#tx-mirror", (e) => [...e.querySelectorAll(".tok")].map((x) => x.textContent).join() === ">50"), ">50 shows as a token in the box");
  await typeSearch(p, "<20");
  const am2 = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).a), (await rows(p)).map((r) => r.id));
  ok(am2.length && am2.every((a) => a <= 20), "<20 keeps amounts under 20");
  await typeSearch(p, "@ana");
  rr = await rows(p);
  const owners = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).p), rr.map((r) => r.id));
  ok(rr.length && owners.every((o) => o === "2"), "@ana keeps Ana's own", [rr.length, [...new Set(owners)]]);
  await typeSearch(p, "@Shared");
  const sh = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).p), (await rows(p)).map((r) => r.id));
  ok(sh.length && sh.every((o) => o === null), "@shared keeps shared ones");
  await typeSearch(p, "#coffee");
  rr = await rows(p);
  ok(rr.length && rr.every((r) => r.sub.includes("#coffee")), "#coffee keeps tagged ones", rr.length);
  await typeSearch(p, "groceries");
  rr = await rows(p);
  ok(rr.length && rr.every((r) => r.sub.includes("Groceries")), "a category name filters by category");
  ok(await p.$eval("#tx-mirror .tok", (e) => e.textContent === "groceries"), "a category shows as a token");
  const pastMonth = tm > 3 ? tm - 2 : tm + 1; // a month name: the latest one that is not in the future
  const mname = months[pastMonth - 1];
  await typeSearch(p, mname.slice(0, 3).toLowerCase());
  const my = pastMonth > tm ? ty - 1 : ty;
  ok((await title(p)) === mname + " " + my, "a month name opens that month", await title(p));
  await typeSearch(p, "mercadona >50 @alex");
  rr = await rows(p);
  const combo = await core(p, (c, ids) => ids.map((id) => { const x = c.state.tx.find((y) => y.id === id); return x.a >= 50 && x.p === "1" && /mercadona/i.test(x.n); }), rr.map((r) => r.id));
  ok(combo.length && combo.every(Boolean), "words and filters combine", combo.length);
  ok((await p.$$("#tx-mirror .tok")).length === 2, "two tokens, the word stays plain text");

  section("Clearing the search");
  await typeSearch(p, "mercadona >50");
  await p.click('#tx-sbox [data-act="qx"]'); await wait(p);
  ok((await p.inputValue("#tx-q")) === "" && (await title(p)) === T(tm, ty) && (await p.$eval("#tx-scope", (e) => e.hidden)), "× clears the search and the scope bar");
  await p.click("#tx-list"); await wait(p);

  section("Filter: type, categories, Shared only");
  await go(p, "/transactions?mode=year");
  await p.click("#tx-fbtn"); await wait(p, 300);
  ok(await p.$eval("#tx-fpop", (e) => !e.hidden) && (await p.$eval("#dlg", (e) => e.hidden)), "Filter opens a popover under it, not a dialog");
  await p.click('#tx-ftype [data-v="income"]'); await wait(p);
  rr = await rows(p);
  ok(rr.length && rr.every((r) => r.pos), "Income only", rr.length);
  ok(url(p).searchParams.get("type") === "income", "type in the address");
  await p.click('#tx-ftype [data-v="expense"]'); await wait(p);
  ok((await rows(p)).every((r) => !r.pos), "Expenses only");
  await p.click('#tx-ftype [data-v="all"]'); await wait(p);
  await p.click('#tx-fpop [data-fc="Food"]'); await wait(p);
  await p.click('#tx-fpop [data-fc="Groceries"]'); await wait(p);
  rr = await rows(p);
  ok(rr.length && rr.every((r) => /Food|Groceries/.test(r.sub)), "only the categories picked");
  ok(url(p).searchParams.getAll("cat").sort().join() === "Food,Groceries", "categories in the address", p.url());
  ok((await p.$eval("#tx-fl", (e) => e.textContent)) === "Filter · 2", "Filter counts what is on", await p.$eval("#tx-fl", (e) => e.textContent));
  ok(/^\d+ transactions?$/.test(await p.$eval("#tx-fpop .tx-fn", (e) => e.textContent)), "the popover says how many it shows");
  await p.click("#tx-fsw"); await wait(p);
  rr = await rows(p);
  ok(rr.every((r) => r.shared), "Shared only", rr.length);
  await p.click('#tx-fpop [data-f="reset"]'); await wait(p);
  ok((await p.$eval("#tx-fl", (e) => e.textContent)) === "Filter", "Reset clears them");
  await p.keyboard.press("Escape"); await wait(p);
  ok(await p.$eval("#tx-fpop", (e) => e.hidden), "Escape closes it");
  await p.click("#tx-fbtn"); await wait(p, 200); await p.click("#tx-list"); await wait(p);
  ok(await p.$eval("#tx-fpop", (e) => e.hidden), "a click outside closes it");

  section("A day of the month");
  await go(p, "/transactions");
  const dayWith = await core(p, (c) => c.state.tx.find((x) => x.d.slice(0, 7) === c.TODAY.slice(0, 7) && x.type === "expense").d);
  await go(p, "/transactions?day=" + dayWith);
  rr = await rows(p);
  const dayCount = await core(p, (c, d) => c.state.tx.filter((x) => x.d === d).length, dayWith);
  ok(rr.length === dayCount && (await p.$("#tx-daychip .w-chip")), "only that day, with a day chip", [rr.length, dayCount]);
  ok(url(p).searchParams.get("day") === dayWith, "day in the address");
  await p.click('[data-act="dayx"]'); await wait(p);
  ok((await countN(p)) === inMonth, "the chip clears the day");

  section("The person switch");
  await p.click(".tx-head .w-pswitch-b"); await p.click('.tx-head .w-pswitch-m [data-scope="1"]'); await wait(p);
  ok((await p.$eval("#tx-count", (e) => e.textContent)).includes("for Alex"), "count says for Alex");
  rr = await rows(p);
  const scoped = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).p), rr.map((r) => r.id));
  ok(scoped.every((o) => o === "1" || o === null), "Alex sees his own and shared");
  await p.click(".tx-head .w-pswitch-b"); await p.click('.tx-head .w-pswitch-m [data-scope="all"]'); await wait(p);

  section("Keyboard: move, edit, duplicate, delete and undo");
  await go(p, "/transactions");
  const s0 = await selId(p);
  ok(!!s0, "the first row is selected");
  await p.focus("#tx-list");
  await p.keyboard.press("ArrowDown"); await wait(p);
  const s1 = await selId(p);
  ok(s1 && s1 !== s0, "↓ moves the selection");
  const inspName = await p.$eval("#tx-insp .tx-iname", (e) => e.textContent);
  ok(inspName === (await rows(p)).find((r) => r.id === s1).name, "the panel follows", inspName);
  await p.keyboard.press("ArrowUp"); await wait(p);
  ok((await selId(p)) === s0, "↑ moves back");
  ok(await p.evaluate(() => document.activeElement && document.activeElement.dataset.id), "focus stays on the selected row");
  await p.keyboard.press("e"); await wait(p);
  let adds = await p.evaluate(() => window.__adds || []);
  ok(adds.length && adds[adds.length - 1].tx && adds[adds.length - 1].tx.id === s0, "E opens the edit sheet with it");
  await p.keyboard.press("d"); await wait(p);
  adds = await p.evaluate(() => window.__adds || []);
  ok(adds[adds.length - 1].date === TODAY && adds[adds.length - 1].name === (await rows(p))[0].name, "D duplicates to today");
  const before = await countN(p);
  const doomed = await core(p, (c, id) => c.state.tx.find((x) => x.id === id), s0);
  await p.keyboard.press("Backspace"); await wait(p, 500);
  ok((await countN(p)) === before - 1 && !(await rows(p)).some((r) => r.id === s0), "⌫ deletes straight away");
  ok((await toastText(p)).startsWith("Deleted " + (doomed.n || doomed.c)), "a toast offers Undo", await toastText(p));
  ok((await selId(p)) === s1, "the next row takes the selection");
  let gone = await api("GET", "/transactions/id/" + s0).catch(() => null);
  ok(!gone || gone.detail || (Array.isArray(gone) && !gone.length), "gone from the server");
  await undo(p);
  ok((await countN(p)) === before && (await rows(p)).some((r) => r.id === s0), "Undo brings it back");
  const back = await api("GET", "/transactions/id/" + s0);
  ok(Array.isArray(back) && back[0] && back[0].id === s0 && Number(back[0].amount) === doomed.a, "with the same id and amount");
  ok((await selId(p)) === s0, "and selects it");
  await p.focus("#tx-list"); await p.keyboard.press("Delete"); await wait(p, 500);
  ok(!(await rows(p)).some((r) => r.id === s0), "Delete key deletes too");
  await undo(p);

  section("The side panel");
  const noted = await core(p, (c) => { const x = c.state.tx.find((y) => y.note && y.d <= c.TODAY); return x && x.id; });
  if (noted) {
    await go(p, "/transactions?id=" + noted);
    ok((await selId(p)) === noted, "?id= selects the row");
    const note = await core(p, (c, id) => c.state.tx.find((x) => x.id === id).note, noted);
    ok(await p.$eval("#tx-insp", (e, n) => e.textContent.includes(n), note), "the note is shown");
  } else ok(true, "(no note in the demo data)");
  const rec = await core(p, (c) => { const x = c.state.tx.find((y) => y.rid); return x && x.id; });
  await go(p, "/transactions?id=" + rec);
  const href = await p.$eval("#tx-insp .tx-kvl", (a) => a.getAttribute("href"));
  const rid = await core(p, (c, id) => c.state.tx.find((x) => x.id === id).rid, rec);
  ok(href === "/recurring?id=" + encodeURIComponent(rid), "Repeats links to the rule", href);
  ok(await p.$eval("#tx-insp .tx-ih", (e) => !!e.querySelector('[data-act="edit"]')), "Edit sits at the panel's top right");
  ok(await p.$eval("#tx-insp .w-pacts", (e) => [...e.children].map((b) => b.textContent + (b.classList.contains("red") ? "*" : "")).join() === "Duplicate,Delete transaction*"), "Duplicate, then Delete in red, as rows");
  ok(await p.$eval("#tx-insp", (e) => [...e.querySelectorAll(".tx-kvr")].some((r) => r.textContent.startsWith("Who")) && !e.textContent.includes("Whose")), "“Who”, never “Whose”");
  ok(await p.$eval("#tx-insp", (e) => /^Added (by \S+ )?on /.test((e.querySelector(".tx-iadd") || {}).textContent || "") && ![...e.querySelectorAll(".tx-kvr")].some((r) => r.textContent.startsWith("Added"))), "who added it, under the name, not as a row");
  ok(await p.$eval("#tx-insp", (e) => [...e.querySelectorAll(".tx-kvr")].some((r) => /^This year\d+ times/.test(r.textContent))), "the payee's year in one line");
  // A split receipt part, and an imported one: made through restore so they carry a group and a source
  const sp = await core(p, (c) => c.TODAY);
  const gid = "e2e-split";
  const made = await api("POST", "/transactions/restore", [
    { id: "11111111-2222-4333-8444-555555555551", name: "E2E Market", category: "Groceries", tags: [], amount: "30.00", type: "expense", date: sp, person_id: 1, group_id: gid, note: "" },
    { id: "11111111-2222-4333-8444-555555555552", name: "E2E Market", category: "Shopping", tags: [], amount: "12.50", type: "expense", date: sp, person_id: 1, group_id: gid, note: "" },
    { id: "11111111-2222-4333-8444-555555555553", name: "E2E Bank", category: "Food", tags: ["e2e"], amount: "9.99", type: "expense", date: sp, person_id: 2, source: { name: "COMPRA TARJ E2E BANK 1234", date: sp }, import_ref: "e2e-ref-1", note: "From the bank" },
  ]);
  ok(Array.isArray(made) && made.length === 3, "made test transactions");
  await go(p, "/transactions?id=11111111-2222-4333-8444-555555555551");
  ok(await p.$eval("#tx-insp .tx-sibs", (e) => e.textContent.includes("Split receipt, 2 parts")), "split receipt siblings");
  await p.click('#tx-insp [data-sib="11111111-2222-4333-8444-555555555552"]'); await wait(p);
  ok((await selId(p)) === "11111111-2222-4333-8444-555555555552", "a sibling opens");
  await go(p, "/transactions?id=11111111-2222-4333-8444-555555555553");
  ok(await p.$eval("#tx-insp", (e) => e.textContent.includes("COMPRA TARJ E2E BANK 1234")), "Imported from the bank text");
  await p.click('#tx-insp [data-act="dup"]'); await wait(p);
  adds = await p.evaluate(() => window.__adds || []);
  ok(adds[adds.length - 1].date === TODAY && adds[adds.length - 1].name === "E2E Bank" && adds[adds.length - 1].amount === 9.99, "Duplicate opens the sheet prefilled for today", adds[adds.length - 1]);
  await p.click('#tx-insp [data-act="edit"]'); await wait(p);
  adds = await p.evaluate(() => window.__adds || []);
  ok(adds[adds.length - 1].tx && adds[adds.length - 1].tx.id === "11111111-2222-4333-8444-555555555553", "Edit opens the sheet with it");
  await p.click('#tx-insp [data-act="del"]'); await wait(p, 600);
  ok(!(await rows(p)).some((r) => r.id.endsWith("553")), "Delete in the panel");
  await undo(p);
  const im = await api("GET", "/transactions/id/11111111-2222-4333-8444-555555555553");
  ok(im[0] && im[0].import_ref === "e2e-ref-1" && im[0].source && im[0].note === "From the bank", "Undo keeps the bank reference, source and note", im[0]);

  section("Calendar");
  await go(p, "/transactions");
  ok((await p.$$eval("#tx-more [data-act]", (es) => es.map((e) => e.dataset.act))).join() === "select,export", "••• holds Select and Export only");
  await p.click('#tx-vsw [data-view="cal"]'); await wait(p);
  ok(!(await p.$eval("#tx-calp", (e) => e.hidden)) && (await p.$$("#tx-cal .w-rday")).length >= 28, "List | Calendar: the month grid");
  ok(await p.$eval('#tx-vsw [data-view="cal"]', (e) => e.getAttribute("aria-pressed") === "true"), "the switch shows Calendar");
  ok(!(await p.$("#tx-cal-sub")), "no explanation under the month");
  ok(url(p).searchParams.get("view") === "cal", "view=cal in the address");
  const lastOfMonth = await core(p, (c) => c.ds(+c.TODAY.slice(0, 4), +c.TODAY.slice(5, 7), c.dim(+c.TODAY.slice(0, 4), +c.TODAY.slice(5, 7))));
  if (lastOfMonth > TODAY) ok((await p.$$("#tx-cal .w-rday.fut")).length === +lastOfMonth.slice(8) - +TODAY.slice(8), "days still to come are dashed");
  await p.click('#tx-cal [data-cd="' + dayWith + '"]'); await wait(p);
  ok(await p.$eval('#tx-cal [data-cd="' + dayWith + '"]', (e) => e.classList.contains("sel")), "a day is picked");
  const dayNow = await core(p, (c, d) => c.state.tx.filter((x) => x.d === d).length, dayWith);
  ok((await p.$$("#tx-insp .w-row")).length === dayNow, "its transactions on the right", dayNow);
  ok(url(p).searchParams.get("day") === dayWith, "the day in the address");
  // Within the month whatever today is: back a day, unless it is the 1st
  await p.keyboard.press(dayWith.slice(8) === "01" ? "ArrowRight" : "ArrowLeft"); await wait(p);
  ok((await p.$eval("#tx-cal .w-rday.sel", (e) => e.dataset.cd)) !== dayWith, "arrows move the day");
  await p.click('#tx-cal [data-cd="' + dayWith + '"]'); await wait(p);
  await p.click('[data-act="cal-open"]'); await wait(p);
  ok((await p.$eval("#tx-listp", (e) => !e.hidden)) && url(p).searchParams.get("day") === dayWith, "Show in list opens the day in the list");

  section("Select mode and bulk changes");
  await go(p, "/transactions");
  await more(p, "select");
  ok(!(await p.$eval("#tx-bulk", (e) => e.hidden)), "••• › Select: the bulk bar");
  ok(await p.$eval("#tx-done", (e) => !e.hidden) && await p.$eval("#tx-fbtn", (e) => e.hidden), "Done takes Filter's place");
  let r3 = (await rows(p)).filter((r) => !r.pos).slice(0, 3);
  for (const r of r3.slice(0, 2)) await p.click('#tx-list [data-id="' + r.id + '"]');
  await p.click('#tx-list [data-id="' + r3[2].id + '"]', { modifiers: ["Shift"] }); await wait(p);
  ok(/^3 selected/.test(await p.$eval(".tx-bkn", (e) => e.textContent)), "3 picked (one by Shift-click)", await p.$eval(".tx-bkn", (e) => e.textContent));
  const cats0 = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).c), r3.map((r) => r.id));
  const target = cats0.includes("Travel") ? "Healthcare" : "Travel";
  await p.selectOption("#bk-cat", target); await wait(p, 700);
  let cats1 = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).c), r3.map((r) => r.id));
  ok(cats1.every((x) => x === target), "category changed", cats1);
  ok((await toastText(p)).startsWith("Moved 3 to " + target), "toast", await toastText(p));
  await undo(p);
  cats1 = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).c), r3.map((r) => r.id));
  ok(cats1.join() === cats0.join(), "Undo puts each category back", cats1);
  const own0 = await core(p, (c, ids) => ids.map((id) => { const x = c.state.tx.find((y) => y.id === id); return [x.p, x.shc && JSON.stringify(x.shc), x.by]; }), r3.map((r) => r.id));
  await p.selectOption("#bk-who", "shared"); await wait(p, 700);
  let own1 = await core(p, (c, ids) => ids.map((id) => c.state.tx.find((x) => x.id === id).p), r3.map((r) => r.id));
  ok(own1.every((x) => x === null), "set to shared", own1);
  await undo(p);
  own1 = await core(p, (c, ids) => ids.map((id) => { const x = c.state.tx.find((y) => y.id === id); return [x.p, x.shc && JSON.stringify(x.shc), x.by]; }), r3.map((r) => r.id));
  ok(JSON.stringify(own1) === JSON.stringify(own0), "Undo puts each person back", [own0, own1]);
  const exp = await download(p, () => p.click("#bk-exp"));
  ok(exp.text.trim().split(/\r?\n/).length === 4, "export selected: header and 3 rows", exp.text.split("\n").length);
  await p.click("#bk-del"); await wait(p);
  ok(await p.$eval("#dlg .w-dialog h5", (e) => e.textContent === "Delete 3 transactions?"), "several ask first");
  await p.click("#dlg [data-dlg-ok]"); await wait(p, 700);
  ok(!(await rows(p)).some((r) => r3.some((x) => x.id === r.id)), "deleted");
  ok((await toastText(p)) === "Deleted 3 transactions", "toast", await toastText(p));
  await undo(p);
  ok(r3.every(async () => true) && (await rows(p)).filter((r) => r3.some((x) => x.id === r.id)).length === 3, "Undo brings the 3 back");
  await p.click("#bk-all"); await wait(p);
  ok(new RegExp("^" + (await countN(p)) + " selected").test(await p.$eval(".tx-bkn", (e) => e.textContent)), "Select all");
  await p.click("#bk-all"); await wait(p);
  ok(/^0 selected/.test(await p.$eval(".tx-bkn", (e) => e.textContent)), "Select none");
  await p.keyboard.press("Escape"); await wait(p);
  ok(await p.$eval("#tx-bulk", (e) => e.hidden), "Escape leaves Select mode");

  section("Export the list");
  await go(p, "/transactions?q=e2e");
  const csv = await download(p, () => more(p, "export"));
  const lines = csv.text.replace(/^/, "").trim().split(/\r?\n/);
  ok(lines[0] === "date,name,category,amount,type,tags,person,paid_by,shares,note", "CSV header", lines[0]);
  ok(lines.length - 1 === (await countN(p)), "one line per transaction shown", [lines.length - 1, await countN(p)]);
  ok(lines.some((l) => l === TODAY + ",E2E Bank,Food,9.99,expense,#e2e,Ana,,,From the bank"), "a row as written", lines);
  ok(/^wally-transactions-\d{4}-\d\d-\d\d\.csv$/.test(csv.name), "file name", csv.name);
  await go(p, "/transactions?q=" + encodeURIComponent("@shared") + "&mode=month");
  const csv2 = await download(p, () => more(p, "export"));
  const l2 = csv2.text.replace(/^/, "").trim().split(/\r?\n/)[1] || "";
  ok(/,Shared,(Alex|Ana),Alex \d+\.\d\d; Ana \d+\.\d\d,/.test(l2), "shared rows: Shared, who paid, the shares", l2);

  section("Recent searches, instead of saved views");
  await go(p, "/transactions");
  ok(!(await p.$('[data-act="save-view"],#tx-views,[data-view-x],[data-viewx]')), "no saved views anywhere");
  await p.click("#tx-q"); await p.keyboard.type("#coffee >5"); await p.keyboard.press("Enter"); await wait(p);
  await p.click("#tx-list"); await wait(p);
  await p.click('#tx-sbox [data-act="qx"]').catch(() => {}); await wait(p);
  await p.click("#tx-q"); await wait(p, 200);
  ok(await p.$eval("#tx-recent", (e) => !e.hidden && e.textContent.includes("#coffee >5") && e.textContent.includes("Try")), "focusing the empty box shows recent searches and hints");
  await p.click('#tx-recent [data-rq="#coffee >5"]'); await wait(p);
  ok((await p.inputValue("#tx-q")) === "#coffee >5" && (await p.$eval("#tx-recent", (e) => e.hidden)), "a recent search applies", await p.inputValue("#tx-q"));
  await typeSearch(p, "");
  await p.click("#tx-list"); await p.click("#tx-q"); await wait(p, 200);
  await p.click('#tx-recent [data-rins="#"]'); await wait(p);
  ok((await p.inputValue("#tx-q")) === "#" && (await p.evaluate(() => document.activeElement.id)) === "tx-q", "a hint starts the search for you");
  await p.keyboard.press("Escape"); await p.keyboard.press("Escape");

  section("Deep links");
  const d3 = TODAY.slice(0, 8) + "03";
  await go(p, "/transactions?view=cal&day=" + d3);
  ok((await p.$eval("#tx-cal .w-rday.sel", (e) => e.dataset.cd)) === d3, "view=cal&day=");
  await go(p, "/transactions?cat=Food");
  ok((await p.$eval("#tx-fl", (e) => e.textContent)) === "Filter · 1" && (await rows(p)).every((r) => r.sub.includes("Food")), "cat=");
  await go(p, "/transactions?mode=year");
  ok((await checked(p)).join() === "year", "mode=year");
  await go(p, "/transactions?mode=month&month=" + first.slice(0, 7));
  ok((await title(p)) === T(+first.slice(5, 7), +first.slice(0, 4)), "mode=month&month=");
  await go(p, "/transactions?q=mercadona");
  ok((await title(p)) === T(tm, ty) && (await p.inputValue("#tx-q")) === "mercadona" && (await p.$eval('#tx-scope [data-sc="all"]', (e) => e.getAttribute("aria-pressed"))) === "true", "q= searches every month");
  const old = await core(p, (c) => c.state.tx[c.state.tx.length - 5].id);
  await go(p, "/transactions?id=" + old);
  ok((await selId(p)) === old, "id= selects an old one, in its month");
  const vis = await p.$eval('#tx-list [data-id="' + old + '"]', (e) => { const r = e.getBoundingClientRect(), b = document.getElementById("tx-list").getBoundingClientRect(); return r.top >= b.top - 1 && r.bottom <= b.bottom + 1; });
  ok(vis, "and scrolls to it");
  await go(p, "/transactions?focus=search");
  ok(await p.evaluate(() => document.activeElement.id === "tx-q"), "focus=search");
  await p.click("#tx-list"); await p.keyboard.press("/"); await wait(p);
  ok(await p.evaluate(() => document.activeElement.id === "tx-q"), "/ on the page focuses the search");
  await p.keyboard.press("Escape");

  section("Empty states");
  await go(p, "/transactions");
  await typeSearch(p, "zzqxv");
  ok(await p.$eval("#tx-list", (e) => e.textContent.includes("No transactions match “zzqxv”")), "no search results");
  await p.click('#tx-scope [data-sc="range"]'); await wait(p);
  ok(!!(await p.$('#tx-list [data-act="everym"]')), "it offers every month when narrowed");
  await p.click('#tx-list [data-act="clear"]'); await wait(p);
  ok((await p.inputValue("#tx-q")) === "", "Clear filters");
  await go(p, "/transactions?mode=custom&from=2001-01-01&to=2001-01-31");
  ok(await p.$eval("#tx-list", (e) => e.textContent.includes("Nothing in this range yet")), "nothing in this range");
  await p.click('#tx-list [data-act="add"]'); await wait(p);
  ok((await p.evaluate(() => (window.__adds || []).length)) > 0, "Add expense opens the sheet");

  section("1100 px: the panel slides over the list");
  p = await newPage({ w: 1100, h: 800 });
  await go(p, "/transactions");
  ok(!(await p.$eval("#tx-body", (e) => e.classList.contains("open"))), "closed at first");
  await p.click("#tx-list .w-row >> nth=2"); await wait(p, 400);
  ok(await p.$eval("#tx-body", (e) => e.classList.contains("open")), "a click opens it");
  const box = await p.$eval("#tx-insp", (e) => { const r = e.getBoundingClientRect(); return [r.right, innerWidth, getComputedStyle(e).position]; });
  ok(box[2] === "fixed" && Math.abs(box[0] - box[1]) < 2, "over the list, at the edge", box);
  await p.keyboard.press("Escape"); await wait(p, 350);
  ok(!(await p.$eval("#tx-body", (e) => e.classList.contains("open"))), "Escape closes it");
  ok(await p.evaluate(() => !!document.activeElement.dataset.id), "focus goes back to the row");
  await p.keyboard.press("Enter"); await wait(p, 350);
  ok(await p.$eval("#tx-body", (e) => e.classList.contains("open")), "Enter opens it");
  await p.click('#tx-insp [data-act="close"]'); await wait(p, 350);

  section("900 px, dark");
  p = await newPage({ w: 900, h: 800, theme: "dark" });
  await go(p, "/transactions?mode=year");
  ok((await p.$$("#tx-list .w-row")).length > 50, "list at 900 px");
  ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "no sideways scroll");

  section("390 px: phone");
  p = await newPage({ w: 390, h: 844 });
  await go(p, "/transactions");
  ok(await p.$eval(".tx-head .w-pswitch", (e) => getComputedStyle(e).display !== "none") && await p.$eval(".tx-search", (e) => e.getBoundingClientRect().width > 150), "phone header: the person menu, then the search row");
  ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "no sideways scroll");
  const tap = (await rows(p))[1];
  await p.click('#tx-list [data-id="' + tap.id + '"]'); await wait(p, 400);
  ok(await p.$eval("#tx-insp", (e) => { const r = e.getBoundingClientRect(); return getComputedStyle(e).visibility === "visible" && r.left === 0 && r.width === innerWidth; }), "a row opens full screen");
  ok(await p.$eval("#tx-insp .tx-dhero", (e, n) => e.textContent.includes(n), tap.name), "with its name");
  await p.click('#tx-insp [data-act="close"]'); await wait(p, 400);
  ok(await p.$eval("#tx-insp", (e) => getComputedStyle(e).visibility === "hidden"), "‹ Transactions closes it");
  await p.click("#tx-fbtn"); await wait(p, 300);
  ok(await p.$eval("#dlg", (e) => e.querySelector(".w-shh h5").textContent === "Filter" && e.querySelector(".w-shh").textContent === "ResetFilterDone"), "a sheet: Reset · Filter · Done");
  await p.click('#tx-ftype [data-v="income"]'); await wait(p);
  const shownN = await p.$eval("#dlg .tx-fn", (e) => e.textContent);
  await p.click("#dlg [data-dlg-ok]"); await wait(p);
  ok((await rows(p)).every((r) => r.pos) && shownN === (await countN(p)) + " transactions", "income only, and the sheet said how many", shownN);
  ok((await p.$eval("#tx-fl", (e) => e.textContent)) === "Filter · 1" && (await p.$eval("#tx-fbtn", (e) => e.classList.contains("on"))), "Filter says 1 is on");
  await p.click("#tx-fbtn"); await wait(p, 300);
  await p.click('#dlg .w-shh [data-f="reset"]'); await wait(p);
  ok((await p.$eval("#tx-fl", (e) => e.textContent)) === "Filter", "Reset in the sheet clears it");
  await p.click("#dlg [data-dlg-ok]"); await wait(p);
  await p.click("#tx-q"); await wait(p, 200);
  ok(await p.$eval(".tx-cancel", (e) => !e.hidden) && (await p.$eval("#tx-qrec", (e) => !e.hidden)) && (await p.$eval("#tx-fbtn", (e) => getComputedStyle(e.parentNode).display === "none")), "searching: Cancel, recent searches, the buttons step aside");
  await p.click(".tx-cancel"); await wait(p);
  ok(await p.$eval(".tx-cancel", (e) => e.hidden), "Cancel ends the search");
  await pick(p, "year");
  ok((await title(p)).startsWith(String(ty)), "the title menu: the year so far", await title(p));
  await pick(p, "custom"); await wait(p, 300);
  ok(await p.$eval("#dlg .w-shh", (e) => e.textContent === "CancelCustom RangeDone"), "Cancel · Custom Range · Done");
  await p.click('#dlg [data-end="a"]'); await wait(p);
  // The calendar's title opens the months of its year; January is one tap away
  await p.click("#dlg [data-rpick]"); await wait(p, 80);
  ok(await p.$$eval("#dlg .tx-rmon button", (b) => b.length) === 12, "the calendar's title opens the twelve months");
  await p.click('#dlg [data-rmo="1"]'); await wait(p, 80);
  ok(await p.$('#dlg [data-rd="' + ty + '-01-05"]') !== null, "picking January shows its days");
  await p.click('#dlg [data-rd="' + ty + '-01-05"]'); await wait(p, 80);
  await p.click('#dlg [data-rd="' + ty + '-01-20"]'); await wait(p, 80);
  ok((await p.$eval("#dlg .w-glf", (e) => e.textContent)) === "16 days", "it counts the days", await p.$eval("#dlg .w-glf", (e) => e.textContent));
  await p.click("#dlg [data-dlg-ok]"); await wait(p);
  ok(url(p).searchParams.get("mode") === "custom" && url(p).searchParams.get("from") === ty + "-01-05", "Range sheet: custom dates", p.url());
  await go(p, "/transactions?view=cal");
  ok((await p.$$("#tx-cal .w-rday")).length >= 28, "calendar on the phone");
  await p.screenshot({ path: "/dev/null" }).catch(() => {});

  section("Arabic");
  await api("PUT", "/language/ar");
  p = await newPage();
  await go(p, "/transactions");
  ok(await p.evaluate(() => document.documentElement.dir === "rtl"), "right to left");
  ok((await p.$$("#tx-list .w-row")).length > 0, "rows drawn");
  await p.click("#tx-list .w-row >> nth=1"); await p.click('#tx-vsw [data-view="cal"]'); await wait(p);
  await api("PUT", "/language/en");

  // Tidy up the test rows
  for (const id of ["551", "552", "553"]) await api("DELETE", "/transactions/11111111-2222-4333-8444-555555555" + id);
}

/* ============================== empty household, then 20,000 rows ============================== */
async function perf() {
  let people = await api("GET", "/people");
  if (!people.length) { await api("POST", "/people", { name: "Alex" }); await api("POST", "/people", { name: "Ana" }); }
  await api("PUT", "/config", { onboarded: true });
  const ledger = await api("GET", "/ledger");
  if (!ledger.rows.length) {
    section("No transactions at all");
    const p = await newPage();
    await go(p, "/transactions");
    ok(!(await p.$eval("#tx-none", (e) => e.hidden)) && (await p.$eval("#tx-none", (e) => e.textContent.includes("No transactions yet"))), "empty page");
    await p.click('#tx-none [data-act="add"]'); await wait(p);
    ok((await p.evaluate(() => (window.__adds || []).length)) === 1, "“Add your first expense” opens the add sheet");
    await p.context().close();
    section("Adding 20,000 transactions");
    const names = ["Mercadona", "Carrefour", "Bonpreu", "Zara", "Repsol", "Cafe Nomada", "Netflix", "Renfe", "Farmacia", "Decathlon"];
    const cats = ["Groceries", "Groceries", "Groceries", "Shopping", "Car", "Food", "Entertainment", "Travel", "Healthcare", "Shopping"];
    const today = new Date();
    let batch = [];
    for (let i = 0; i < 20000; i++) {
      const d = new Date(today.getTime() - Math.floor(i / 9) * 864e5).toISOString().slice(0, 10);
      const k = i % 10;
      batch.push({ name: names[k], category: cats[k], tags: i % 7 === 0 ? ["coffee"] : [], amount: (5 + ((i * 37) % 200) + (i % 100) / 100).toFixed(2), type: i % 50 === 0 ? "income" : "expense", date: d, person_id: i % 3 === 0 ? null : (i % 3), note: "" });
      if (batch.length === 2000) { await api("POST", "/transactions/import", batch); batch = []; }
    }
  }
  section("20,000 rows");
  const p = await newPage();
  const t0 = Date.now();
  await go(p, "/transactions?mode=all");
  const loadMs = Date.now() - t0;
  const n = await countN(p);
  ok(n >= 20000, "all " + n + " listed", n);
  const dom = await p.$$eval("#tx-list .w-row", (es) => es.length);
  ok(dom < 150, "only what is on screen is drawn (" + dom + " rows in the page)");
  console.log("     page ready in " + loadMs + " ms");
  // Scroll the list end to end and measure frames
  const frames = await p.evaluate(async () => {
    const box = document.getElementById("tx-list"), times = [];
    let last = performance.now();
    for (let i = 0; i < 120; i++) {
      box.scrollTop = (box.scrollHeight - box.clientHeight) * (i / 119);
      await new Promise((r) => requestAnimationFrame(r));
      const now = performance.now(); times.push(now - last); last = now;
    }
    times.sort((a, b) => a - b);
    return { median: times[60], p95: times[114], max: times[119], rowsAtEnd: box.querySelectorAll(".w-row").length };
  });
  console.log("     scroll frames: median " + frames.median.toFixed(1) + " ms, p95 " + frames.p95.toFixed(1) + " ms, max " + frames.max.toFixed(1) + " ms");
  ok(frames.p95 < 40, "scrolling stays smooth (p95 frame under 40 ms)", frames);
  ok(frames.rowsAtEnd > 0 && frames.rowsAtEnd < 150, "rows drawn at the end of the list");
  const lastRow = await p.evaluate(async () => { const c = await import("/js/core.js"); return c.state.tx[c.state.tx.length - 1].id; });
  ok(!!(await p.$('#tx-list [data-id="' + lastRow + '"]')), "the oldest row is reachable");
  // Search across 20,000
  await p.fill("#tx-q", "");
  const ts = await p.evaluate(async () => {
    const q = document.getElementById("tx-q"), out = [];
    for (const s of ["m", "me", "mer", "merc", "merca"]) {
      const t0 = performance.now(); q.value = s; q.dispatchEvent(new Event("input"));
      await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
      out.push(performance.now() - t0);
    }
    return out;
  });
  console.log("     search keystrokes: " + ts.map((x) => x.toFixed(0)).join(", ") + " ms");
  ok(Math.max(...ts) < 250, "each keystroke redraws in under 250 ms", ts);
  // Keyboard through a long list
  await p.fill("#tx-q", ""); await wait(p, 300);
  await p.focus("#tx-list");
  const tk = Date.now();
  for (let i = 0; i < 40; i++) await p.keyboard.press("ArrowDown");
  const per = (Date.now() - tk) / 40;
  console.log("     ↓ " + per.toFixed(0) + " ms a step");
  const sid = await selId(p);
  const idx = await p.evaluate(async (id) => { const c = await import("/js/core.js"); return c.state.tx.findIndex((x) => x.id === id); }, sid);
  ok(idx === 40, "↓ 40 times selects the 41st", idx);
  ok(per < 120, "arrows keep up");
  // Select all and bulk delete a part, then undo
  await p.goto(BASE + "/transactions?q=bonpreu&mode=month", { waitUntil: "networkidle" }); await wait(p, 300);
  ok((await countN(p)) > 0, "a narrowed list");
}

(async () => {
  browser = await chromium.launch();
  try {
    if (SUITE === "perf") await perf(); else await demo();
  } catch (e) {
    fails++;
    console.log("  FAIL exception: " + (e && e.stack || e));
  }
  await browser.close();
  section("Console errors");
  ok(errors.length === 0, "no console errors", errors);
  console.log("\n" + (fails ? fails + " failed" : "all passed"));
  process.exit(fails ? 1 : 0);
})();