/* 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);