tests/e2e/review.mjs
Rama main · réplica de lectura del repositorio original.
/* Month in review and the command bar (2.1), on real servers.
Starts a demo server and an empty one of its own:
REPO=/path/to/Wally PYTHON=/path/to/python PORT=8806 node review.mjs
Screenshots go to SHOTS (a folder) when it is set. */
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { mkdtempSync, existsSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const REPO = process.env.REPO || process.cwd();
const PYTHON = process.env.PYTHON || "python3";
const PORT = +(process.env.PORT || 8806);
const SHOTS = process.env.SHOTS || "";
if (SHOTS && !existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true });
let failures = 0;
const ok = (cond, msg) => { if (cond) console.log(" ok " + msg); else { failures++; console.log(" FAIL " + msg); } };
const servers = [];
async function startServer(port, demo) {
const data = mkdtempSync(join(tmpdir(), "wally-review-"));
const env = { ...process.env, WALLY_DATA: data };
if (demo) env.DEMO = "true"; else delete env.DEMO;
servers.push(spawn(PYTHON, ["-m", "uvicorn", "utils.dev_server:app", "--port", String(port)], { cwd: REPO, env, stdio: "ignore" }));
const base = "http://localhost:" + port;
for (let i = 0; i < 80; i++) {
try { if ((await fetch(base + "/api/auth/status")).ok) return base; } catch (e) { /* not yet */ }
await new Promise((r) => setTimeout(r, 250));
}
throw new Error("server on " + port + " did not start");
}
const api = (base, path, method = "GET", body) => fetch(base + "/api" + path, { method, headers: { "Content-Type": "application/json", "X-Wally-Person": "1" }, body: body ? JSON.stringify(body) : undefined }).then((r) => r.json());
/* The month that closed last, in this machine's day, as the browser works it out */
const now = new Date();
const lastClosed = now.getMonth() === 0 ? [now.getFullYear() - 1, 12] : [now.getFullYear(), now.getMonth()];
const mk = (q) => q[0] + "-" + String(q[1]).padStart(2, "0");
const prevOf = (q) => (q[1] === 1 ? [q[0] - 1, 12] : [q[0], q[1] - 1]);
const LAST = mk(lastClosed), PREV = mk(prevOf(lastClosed));
const euros = (s) => Number(String(s).replace(/[^\d-−]/g, "").replace("−", "-"));
const near = (a, b, d = 1) => Math.abs(a - b) <= d;
const browser = await chromium.launch();
async function open(base, path, { width = 1440, height = 900, theme = "light", person = "1", touch = false } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, serviceWorkers: "block", hasTouch: touch });
await ctx.addInitScript((p) => { localStorage.setItem("wally.person", p); localStorage.removeItem("wally.scope"); }, person);
const page = await ctx.newPage();
page.errors = [];
page.on("console", (m) => { if (m.type() === "error") page.errors.push(m.text()); });
page.on("pageerror", (e) => page.errors.push("PAGEERROR " + e.message + " " + (e.stack || "").split("\n")[1]));
await page.goto(base + path, { waitUntil: "networkidle" });
return page;
}
const shot = async (page, name, full) => { if (SHOTS) await page.waitForTimeout(350), await page.screenshot({ path: join(SHOTS, name + ".png"), fullPage: !!full }); };
/* Errors raised inside other pages' modules (reached through links and the command bar) are reported, not counted */
const OTHERS = /\/js\/pages\/(dashboard|transactions|recurring|settings)/;
const noErrors = (page, what) => {
const mine = page.errors.filter((e) => !OTHERS.test(e)), theirs = page.errors.filter((e) => OTHERS.test(e));
if (theirs.length) console.log(" note " + what + ": errors in other pages: " + [...new Set(theirs)].join(" | "));
ok(mine.length === 0, what + ": no console errors" + (mine.length ? " — " + mine.join(" | ") : ""));
};
const kpi = (page, i) => page.locator(".w-mrev .w-mrtop .w-mrtile").nth(i).locator("b").textContent();
try {
const base = await startServer(PORT, true);
console.log("Demo household: " + base + ", last closed month " + LAST);
const sum = await api(base, `/summary/monthly?from=${PREV}&to=${LAST}`);
const M = sum.months.find((m) => m.month === LAST);
const before = await api(base, `/summary/monthly?from=${mk([lastClosed[0] - 1, lastClosed[1]])}&to=${PREV}`);
/* ---------- the figures against the API ---------- */
{
const page = await open(base, "/review");
await page.waitForSelector(".w-mrev .w-mrtop .w-mrtile");
const title = await page.textContent("#mr-title");
ok(new RegExp(String(lastClosed[0])).test(title), "review: opens on the last closed month (" + title + ")");
ok(page.url().endsWith("/review"), "review: /review with no month is the last closed one");
ok(euros(await kpi(page, 0)) === Math.round(M.income / 100), "review: Came in = /summary/monthly income (" + (await kpi(page, 0)) + ")");
ok(euros(await kpi(page, 1)) === Math.round(M.expense / 100), "review: Went out = /summary/monthly expense");
ok(euros(await kpi(page, 2)) === Math.round(M.recurring / 100), "review: Bills = /summary/monthly recurring");
ok(euros(await kpi(page, 3)) === Math.round((M.expense - M.recurring) / 100), "review: Everyday = expense − recurring");
ok(euros(await page.textContent(".w-mrhero .big")) === Math.round(Math.abs(M.income - M.expense) / 100), "review: Kept = income − expense");
// The story order, and the two blocks that went
const order = await page.$$eval(".w-mrev h2", (l) => l.map((h) => h.textContent));
ok(order.join("|") === "Where it went|What changed|Each of you|Day by day|Bills|Worth knowing", "review: one reading order (" + order.join(", ") + ")");
ok(!(await page.textContent(".w-mrev")).includes("Who spent on what") && await page.locator(".w-mrev .w-mrcal").count() === 0, "review: no second breakdown, no calendar grid");
ok(!(await page.textContent(".w-mrev")).includes("dashed line") && !(await page.textContent(".w-mrev")).includes("Click a month"), "review: no notes on how to read the charts");
// Categories
const rows = await page.$$eval(".w-mrev a.w-mrwrow", (l) => l.map((a) => [a.dataset.cat, a.querySelector(":scope > b").textContent]));
const catOk = rows.every(([c, v]) => M.categories[c] && euros(v) === Math.round(M.categories[c].total / 100));
ok(rows.length > 3 && catOk, "review: every category total matches the API (" + rows.length + " categories)");
ok(await page.locator(".w-mrev .w-mrkey").count() === 1 && await page.locator(".w-mrev a.w-mrwrow .w-mrsplit i").count() > rows.length, "review: who spent it, inside each bar");
// People: always everyone, each by share
const cards = page.locator(".w-mrev .w-mrpc");
ok(await cards.count() === 2, "review: Each of you shows both people");
for (const [i, id] of [[0, "1"], [1, "2"]]) {
const spent = euros(await cards.nth(i).locator(".w-mrkv").nth(1).locator("b").textContent());
ok(near(spent, M.people[id].expense / 100), "review: person " + id + " spent matches the API by share (" + spent + " vs " + (M.people[id].expense / 100).toFixed(2) + ")");
}
const shared = await page.textContent("#mr-shared");
ok(/shared cost/.test(shared) && !/owed|closed even/.test(shared), "review: shared costs and who paid them, no who-owed-whom (" + shared.slice(0, 90) + "…)");
await shot(page, "review-1440", true);
// Scope: every figure but the people block
await page.click('.w-mrh [data-mrsc="1"]');
await page.waitForFunction(() => document.querySelector('.w-mrh [data-mrsc="1"]').getAttribute("aria-pressed") === "true");
ok(near(euros(await kpi(page, 1)), M.people["1"].expense / 100), "scope: Went out becomes Alex’s share");
ok(await page.locator(".w-mrev .w-mrpc").count() === 2 && await page.locator(".w-mrev .w-mrpc.dim").count() === 1, "scope: Each of you still shows both, the other dimmed");
await page.click('.w-mrh [data-mrsc="all"]');
// Arrows through closed months, and the keyboard
ok(await page.isDisabled("#mr-next"), "arrows: next is off on the last closed month");
await page.click("#mr-prev");
await page.waitForFunction((p) => location.search === "?m=" + p, PREV);
ok(true, "arrows: previous month, in the address");
await page.locator("body").press("ArrowRight");
await page.waitForFunction((p) => location.search === "?m=" + p, LAST);
ok(true, "arrows: → goes forward again");
// The chart: one bar per month, click to open
const bars = page.locator(".w-mrev .w-mrbars [data-mrm]");
ok(await bars.count() >= 6, "chart: a bar per month (" + await bars.count() + ")");
await bars.nth(0).click();
await page.waitForFunction((l) => location.search !== "?m=" + l, LAST);
ok(true, "chart: clicking a month opens it");
// Out of range months are clamped
await page.goto(base + "/review?m=2099-01", { waitUntil: "networkidle" });
ok((await page.textContent("#mr-title")).includes(String(lastClosed[0])), "review: a month not over yet shows the last closed one");
// Tooltips and links
await page.hover(".w-mrev a.w-mrwrow >> nth=0");
ok(await page.locator("#tip.on").count() === 1 && (await page.textContent("#tip")).includes("Usual"), "tooltip: a category says spent, last month and usual");
const cat0 = await page.getAttribute(".w-mrev a.w-mrwrow >> nth=0", "data-cat");
await page.click(".w-mrev a.w-mrwrow >> nth=0");
await page.waitForURL("**/transactions?**");
ok(page.url().includes("month=" + LAST) && page.url().includes("cat=" + encodeURIComponent(cat0)), "link: a category opens its transactions for the month");
await page.goBack();
await page.waitForSelector(".w-mrev");
await page.click(".w-mrev [data-mr-close]");
await page.waitForFunction(() => location.pathname === "/");
ok(true, "close: back to the Dashboard");
noErrors(page, "review");
await page.context().close();
}
/* ---------- never shown by itself: Summary offers last month's recap ---------- */
{
await api(base, "/me/prefs", "PUT", { reviewed: "2000-01" });
const page = await open(base, "/");
await page.waitForTimeout(800);
ok(new URL(page.url()).pathname === "/", "opening Wally is never interrupted by Month in review");
// The recap card shows only once a recap is ready to read: on a closed month in Summary
await page.goto(base + "/?month=" + LAST, { waitUntil: "networkidle" });
await page.waitForTimeout(500);
ok(await page.locator('#s-recapw a[href="/review?m=' + LAST + '"]').count() === 1, "Summary on last month offers its recap");
await page.click("#s-recapw a");
await page.waitForFunction((l) => location.search === "?m=" + l, LAST);
ok(true, "the recap card opens that month");
noErrors(page, "recap card");
await page.context().close();
}
/* ---------- Export as PDF: the report in A4 pages, from the button or from ⌘P ---------- */
{
const page = await open(base, "/review?m=" + LAST);
await page.waitForSelector(".w-mrh [data-menu=mrmore]");
await page.evaluate(() => { window.__printed = 0; window.print = () => { window.__printed++; }; });
ok(await page.locator(".w-mrh [data-mr-pdf]").isHidden(), "Export as PDF waits in the ••• menu");
const title0 = await page.title();
await page.click(".w-mrh [data-menu=mrmore]");
await page.click(".w-mrh [data-mr-pdf]");
await page.waitForFunction(() => window.__printed === 1);
const sheets = await page.locator("#w-print .pr-sheet").count();
ok(sheets >= 1 && sheets <= 3, "the report is laid out in " + sheets + " A4 pages");
ok((await page.title()).startsWith("Wally – "), "the file is named after the month: " + await page.title());
ok((await page.locator("#w-print .pr-pn").allInnerTexts()).every((x, i) => x === "Page " + (i + 1) + " of " + sheets), "every page has its number");
ok(await page.locator("#w-print").evaluate((r) => [...r.querySelectorAll(".pr-body")].every((b) => b.scrollHeight <= b.clientHeight + 1)), "nothing runs off a page");
ok(await page.locator("#w-print h1").innerText() !== "" && await page.locator("#w-print .pr-cr:not(.hd)").count() > 0, "title, figures and categories are in it");
// Usual: the average of the 6 months before, printed under each total
const six = before.months.filter((m) => m.month < LAST).slice(-6);
const usualExp = six.reduce((s, m) => s + m.expense, 0) / six.length / 100;
const usualText = await page.locator("#w-print .pr-kpi > div").nth(1).locator("small").textContent();
ok(near(euros(usualText), Math.round(usualExp)), "report: usual = average of the 6 months before (" + usualText + " vs " + Math.round(usualExp) + ")");
ok(sheets === 2 && (await page.locator("#w-print .pr-sheet").nth(1).locator("h2").first().innerText()).startsWith("Each of you"), "page 2 starts with Each of you");
const billsKv = await page.locator("#w-print .pr-sheet").nth(1).locator(".pr-kv").nth(1).innerText();
ok((/[\d,.]+\s*€\s+in\s+\d+\s+bills?/.test(billsKv) && /First\s+\S/.test(billsKv)) || /No bills/.test(billsKv), "report: next month's bills, then the first one on its own line (" + billsKv.replace(/\s+/g, " ") + ")");
ok(await page.locator("#w-print .pr-grid").count() === 0 && await page.locator("#w-print .pr-split i").count() > 0, "the report has no day grid, and who spent it is in the bars");
const pdf = (await page.pdf({ preferCSSPageSize: true, printBackground: true })).toString("latin1");
const pages = (pdf.match(/\/Type\s*\/Page[^s]/g) || []).length;
ok(pages === sheets, "the PDF has as many pages as the report: " + pages);
await page.evaluate(() => dispatchEvent(new Event("afterprint")));
ok(await page.locator("#w-print").count() === 0 && await page.title() === title0 && !(await page.getAttribute("body", "class") || "").includes("pr-on"), "after printing, the page is as it was");
await page.evaluate(() => dispatchEvent(new Event("beforeprint")));
ok(await page.locator("#w-print .pr-sheet").count() === sheets, "⌘P prints the same report");
await page.evaluate(() => dispatchEvent(new Event("afterprint")));
noErrors(page, "export as PDF");
await page.context().close();
}
/* ---------- the phone: five pages ---------- */
{
const page = await open(base, "/review", { width: 390, height: 844, touch: true });
await page.waitForSelector(".w-mrstory");
ok(await page.locator(".w-mrev").isHidden() && await page.locator('[data-sp="0"]').isVisible(), "phone: the story, page 1");
ok(await page.locator(".w-tabbar").isHidden(), "phone: full screen, no tab bar");
await shot(page, "review-phone-1");
await page.click("#mr-snext");
ok(await page.locator('[data-sp="1"]').isVisible(), "phone: Tap for more → page 2, where it went");
await shot(page, "review-phone-2");
const box = await page.locator("#mr-pages").boundingBox();
await page.mouse.click(box.x + box.width * 0.8, box.y + 40);
ok(await page.locator('[data-sp="2"]').isVisible(), "phone: tapping the page goes on → page 3");
await shot(page, "review-phone-3");
await page.mouse.click(box.x + box.width * 0.1, box.y + 40);
ok(await page.locator('[data-sp="1"]').isVisible(), "phone: tapping the start side goes back");
// A swipe
await page.evaluate(() => {
const el = document.getElementById("mr-pages");
const touch = (x) => new Touch({ identifier: 1, target: el, clientX: x, clientY: 200 });
el.dispatchEvent(new TouchEvent("touchstart", { touches: [touch(300)], changedTouches: [touch(300)], bubbles: true }));
el.dispatchEvent(new TouchEvent("touchend", { touches: [], changedTouches: [touch(120)], bubbles: true }));
});
ok(await page.locator('[data-sp="2"]').isVisible(), "phone: swiping goes on");
await page.click("#mr-snext");
ok(await page.locator('[data-sp="3"]').isVisible() && (await page.textContent('[data-sp="3"]')).includes("Each of you"), "phone: page 4, each of you");
await page.click("#mr-snext");
ok(await page.locator('[data-sp="4"]').isVisible() && await page.locator("#mr-snext").isHidden(), "phone: page 5 is the last");
const p5 = await page.textContent('[data-sp="4"]');
ok(p5.includes("Day by day") && p5.includes("Bills") && p5.includes("Worth knowing"), "phone: the last page has day by day, bills and worth knowing");
await shot(page, "review-phone-5");
await page.click(".w-mrstory [data-mr-prev]");
ok((await page.textContent(".w-mrstop .w-mrsm")).length > 0 && page.url().endsWith("?m=" + PREV) && await page.locator('[data-sp="0"]').isVisible(), "phone: the arrows step months, from page 1");
ok(await page.locator(".w-mrstop [data-menu=mrmore]").isVisible() && await page.locator(".w-mrstory .w-mrsc").isVisible(), "phone: ••• beside close, Household | each person under");
await page.click(".w-mrstory [data-mr-close]");
await page.waitForFunction(() => location.pathname === "/");
ok(true, "phone: close goes to the Dashboard");
noErrors(page, "phone");
await page.context().close();
}
/* ---------- dark, tablet, Arabic ---------- */
{
const page = await open(base, "/review", { width: 900, height: 900, theme: "dark" });
await page.waitForSelector(".w-mrev .w-mrtop .w-mrtile");
const over = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth);
ok(!over, "tablet: nothing wider than the screen");
await shot(page, "review-900-dark", true);
await page.setViewportSize({ width: 1100, height: 900 });
await shot(page, "review-1100-dark");
await page.evaluate(() => fetch("/api/language/ar", { method: "PUT", headers: { "X-Wally-Person": "1" } }));
await page.setViewportSize({ width: 1440, height: 900 });
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector(".w-mrev .w-mrtop .w-mrtile");
ok(await page.evaluate(() => document.documentElement.dir) === "rtl", "arabic: right to left");
await shot(page, "review-ar", true);
await page.setViewportSize({ width: 390, height: 844 });
await shot(page, "review-ar-phone");
await page.evaluate(() => fetch("/api/language/en", { method: "PUT", headers: { "X-Wally-Person": "1" } }));
noErrors(page, "dark and Arabic");
await page.context().close();
}
/* ---------- the command bar ---------- */
{
const page = await open(base, "/");
await page.waitForSelector("#app:not([hidden])");
await page.waitForTimeout(300);
const before = await page.evaluate(() => document.activeElement && document.activeElement.tagName);
await page.keyboard.press("Control+k");
await page.waitForSelector("#cmd:not([hidden])");
ok(await page.evaluate(() => document.activeElement.id) === "cmd-q", "cmd: Ctrl+K opens it, focus in the box");
ok(await page.getAttribute("#cmd .w-cmd", "aria-modal") === "true" && await page.getAttribute("#cmd-q", "role") === "combobox", "cmd: a modal dialog with a combobox");
await shot(page, "cmd-open");
await page.keyboard.press("Escape");
ok(await page.locator("#cmd").isHidden(), "cmd: Esc closes it");
ok(await page.evaluate(() => document.activeElement && document.activeElement.tagName) === before, "cmd: focus goes back");
await page.keyboard.press("Control+k");
await page.keyboard.press("Control+k");
ok(await page.locator("#cmd").isHidden(), "cmd: Ctrl+K again closes it");
// Jump to a page with arrows and Enter
await page.keyboard.press("Control+k");
await page.keyboard.type("recur");
const first = await page.textContent(".w-cmdi.hl .l");
ok(first === "Recurring", "cmd: typing finds Recurring (" + first + ")");
await page.keyboard.press("Enter");
await page.waitForFunction(() => location.pathname === "/recurring" && document.getElementById("main").dataset.page === "recurring");
ok(await page.locator("#cmd").isHidden(), "cmd: Enter jumps to Recurring and closes");
// A settings pane, down the list with the arrows
await page.keyboard.press("Control+k");
await page.keyboard.type("api keys");
const labels = await page.locator(".w-cmdi .l").allTextContents();
const idx = labels.indexOf("API keys");
ok(idx >= 0, "cmd: finds the API keys pane");
for (let i = 0; i < idx; i++) await page.keyboard.press("ArrowDown");
ok(await page.getAttribute("#cmd-q", "aria-activedescendant") === "cmd-o" + idx, "cmd: arrows move the highlight (aria-activedescendant)");
await page.keyboard.press("Enter");
await page.waitForFunction(() => location.pathname === "/settings/keys");
ok(true, "cmd: Settings › API keys");
// Month in review
await page.keyboard.press("Control+k");
await page.keyboard.type("month in");
await page.keyboard.press("Enter");
await page.waitForFunction(() => location.pathname === "/review");
ok(true, "cmd: Month in review");
// A transaction by name, a rule, a category
const ledger = await api(base, "/ledger");
const ni = ledger.columns.indexOf("name"), ii = ledger.columns.indexOf("id");
const payee = ledger.rows[0][ni];
await page.keyboard.press("Control+k");
await page.keyboard.type(payee);
const groups = await page.locator(".w-cmdg").allTextContents();
ok(groups.includes("Transactions") && groups.includes("Search"), "cmd: finds transactions by name (" + payee + ")");
const txIndex = (await page.locator(".w-cmdi .l").allTextContents()).findIndex((l) => l.startsWith(payee + ","));
await page.locator(".w-cmdi").nth(txIndex).click();
const picked = await page.waitForFunction(() => location.pathname === "/transactions" && new URLSearchParams(location.search).get("id")).then((h) => h.jsonValue());
ok(ledger.rows.some((r) => r[ii].replace(/-/g, "") === picked.replace(/-/g, "") && r[ni] === payee), "cmd: the transaction opens selected");
const rules = await api(base, "/recurring");
await page.keyboard.press("Control+k");
await page.keyboard.type(rules[0].name);
await page.locator(".w-cmdg", { hasText: "Recurring" }).waitFor();
await page.locator('[role=group]:has(.w-cmdg:text-is("Recurring")) .w-cmdi').first().click();
await page.waitForFunction(() => location.pathname === "/recurring" && location.search.includes("id="));
ok(true, "cmd: a rule opens in Recurring (" + rules[0].name + ")");
// Show only one person
await page.keyboard.press("Control+k");
await page.keyboard.type("only ana");
await page.keyboard.press("Enter");
ok(await page.evaluate(() => localStorage.getItem("wally.scope")) === "2", "cmd: Show only Ana");
await page.keyboard.press("Control+k");
await page.keyboard.type("everyone");
await page.keyboard.press("Enter");
// Nothing found
await page.keyboard.press("Control+k");
await page.keyboard.type("§");
ok((await page.textContent("#cmd-res")).includes("Nothing found"), "cmd: nothing found says what to try");
// Tab stays inside
for (let i = 0; i < 4; i++) await page.keyboard.press("Tab");
ok(await page.evaluate(() => document.getElementById("cmd").contains(document.activeElement)), "cmd: Tab stays inside");
await page.click("#cmd", { position: { x: 10, y: 10 } });
ok(await page.locator("#cmd").isHidden(), "cmd: a click outside closes it");
// Quick add: "12 coffee ana"
const coffee = ledger.rows.find((r) => r[ni] && /caf|coffee/i.test(r[ni]));
const word = coffee ? coffee[ni].split(/\s+/)[0].toLowerCase() : "coffee";
await page.keyboard.press("Control+k");
await page.keyboard.type("12 " + word + " ana");
const add = await page.textContent(".w-cmdi.hl .l");
ok(/^Add 12[.,]00/.test(add) && add.endsWith("for Ana"), "cmd: quick add reads amount, payee and person (" + add + ")");
await shot(page, "cmd-quickadd");
await page.keyboard.press("Enter");
await page.waitForSelector("#add-modal:not([hidden])");
const amt = await page.inputValue("#add-amt").catch(() => "");
ok(/^12/.test(amt), "cmd: the add sheet opens with the amount (" + amt + ")");
await page.keyboard.press("Escape");
// Phone width
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(300);
if (!(await page.locator("#add-modal").isHidden())) { await page.keyboard.press("Escape"); const d = page.locator("[data-dlg-ok]"); if (await d.count()) await d.click(); }
await page.keyboard.press("Control+k");
await page.waitForSelector("#cmd:not([hidden])");
const w = await page.$eval("#cmd .w-cmd", (e) => e.getBoundingClientRect().width);
ok(w > 360, "cmd: full width on a phone");
await shot(page, "cmd-phone");
await page.click("[data-cmd-x]");
ok(await page.locator("#cmd").isHidden(), "cmd: the Esc key cap closes it on touch");
noErrors(page, "command bar");
await page.context().close();
}
/* ---------- nothing to review yet ---------- */
{
const empty = await startServer(PORT + 1, false);
await api(empty, "/people", "POST", { name: "Sam" });
await api(empty, "/config", "PUT", { onboarded: true });
const page = await open(empty, "/review");
await page.waitForSelector(".w-mrempty");
ok((await page.textContent(".w-mrempty")).includes("No month to review yet"), "empty: no month to review yet");
await page.waitForTimeout(400);
ok(new URL(page.url()).pathname === "/review", "empty: the first-of-month check does nothing");
await shot(page, "review-empty");
await page.keyboard.press("Control+k");
await page.keyboard.type("12 bread");
ok((await page.textContent(".w-cmdi.hl small")).includes("Pick a category"), "empty: quick add with nothing to learn from asks for a category next");
noErrors(page, "empty");
await page.context().close();
}
} catch (e) {
failures++;
console.log(" FAIL " + (e.stack || e.message));
} finally {
await browser.close();
servers.forEach((p) => p.kill());
}
console.log(failures ? failures + " failed" : "all passed");
process.exit(failures ? 1 : 0);