tests/e2e/add.mjs
Rama main · réplica de lectura del repositorio original.
/* Browser tests for the add and edit sheet (web/js/add.js).
Needs a dev server with demo data (people 1 and 2):
WALLY_DATA=/tmp/wally-add DEMO=true python -m uvicorn utils.dev_server:app --port 8803
Run: BASE=http://localhost:8803 node tests/e2e/add.mjs (playwright must resolve)
SHOTS=/some/dir also saves screenshots. The last scenarios change the household (3 people, then 1). */
import { chromium } from "playwright";
const BASE = process.env.BASE || "http://localhost:8803";
const SHOTS = process.env.SHOTS || "";
let failed = 0, passed = 0;
function check(ok, name, extra) {
if (ok) { passed++; console.log(" ok " + name); }
else { failed++; console.log(" FAIL " + name + (extra !== undefined ? " → " + JSON.stringify(extra) : "")); }
}
function section(name) { console.log("\n" + name); }
const browser = await chromium.launch();
const errors = [];
let allowNetErrors = false;
async function newPage(opts = {}) {
const ctx = await browser.newContext({
viewport: { width: opts.width || 1440, height: opts.height || 900 }, colorScheme: opts.theme || "light",
serviceWorkers: "block", hasTouch: !!opts.touch, isMobile: !!opts.touch,
});
await ctx.addInitScript((who) => { if (!localStorage.getItem("wally.person")) localStorage.setItem("wally.person", who); }, opts.person || "1");
const page = await ctx.newPage();
page.on("console", (m) => {
if (m.type() !== "error") return;
if (allowNetErrors && /Failed to load resource|ERR_FAILED/.test(m.text())) return;
errors.push(m.text());
});
page.on("pageerror", (e) => errors.push("pageerror: " + e.message));
await page.goto(BASE + "/", { waitUntil: "networkidle" });
await page.waitForSelector("#app:not([hidden])");
return page;
}
const api = (page, method, path, body) => page.evaluate(async ([m, p, b]) => {
const r = await fetch("/api" + p, { method: m, headers: { "Content-Type": "application/json", "X-Wally-Person": localStorage.getItem("wally.person") }, body: b === undefined ? undefined : JSON.stringify(b) });
const text = await r.text();
return { status: r.status, data: text ? JSON.parse(text) : null };
}, [method, path, body]);
const core = (page, fn, arg) => page.evaluate(async ([src, a]) => { const c = await import("/js/core.js"); return new Function("c", "a", "return (" + src + ")(c, a)")(c, a); }, [fn.toString(), arg]);
async function open(page, opts) {
if (opts === undefined) await page.keyboard.press("n");
else await page.evaluate(async (o) => {
const m = await import("/js/add.js"), c = await import("/js/core.js");
if (o.txId) o.tx = c.state.tx.find((x) => x.id === o.txId);
if (o.ruleId) o.rule = c.state.rules.find((x) => x.id === o.ruleId);
m.openAdd(o);
}, opts);
await page.waitForSelector("#add-form");
}
const isOpen = (page) => page.evaluate(() => !document.getElementById("add-modal").hidden);
const text = (page, sel) => page.$eval(sel, (n) => n.textContent.trim()).catch(() => null);
const visible = (page, sel) => page.$eval(sel, (n) => !!(n.offsetParent || n.getClientRects().length) && !n.closest("[hidden]")).catch(() => false);
const focused = (page) => page.evaluate(() => { const a = document.activeElement; return a ? a.id || a.getAttribute("data-ac") || a.getAttribute("data-aw") || a.tagName : null; });
async function toastText(page, re, timeout = 5000) {
try {
await page.waitForFunction((src) => new RegExp(src).test(document.getElementById("toast-t").textContent) && document.getElementById("toast").classList.contains("on"), re.source, { timeout });
} catch (e) { /* returned below */ }
return text(page, "#toast-t");
}
async function saveWith(page, keys = "Control+Enter", pathRe = /\/api\/transactions$/) {
await clearToast(page);
const resp = page.waitForResponse((r) => pathRe.test(new URL(r.url()).pathname) && r.request().method() !== "GET", { timeout: 8000 });
await page.keyboard.press(keys);
const r = await resp;
return r.json();
}
async function clickSave(page, pathRe = /\/api\/transactions$/) {
await clearToast(page);
const resp = page.waitForResponse((r) => pathRe.test(new URL(r.url()).pathname) && r.request().method() !== "GET", { timeout: 8000 });
await page.click("#add-go");
return (await resp).json();
}
// The category: on the desktop a combobox (type and Enter); on a phone a page with the list
async function pickCat(p, name) {
if (!(await p.$("#add-cat"))) { await p.click("#add-catrow"); await p.click('[data-pcat="' + name + '"]'); return; }
await p.click("#add-cat");
await p.fill("#add-cat", name);
await p.keyboard.press("Enter");
}
// Repeat is a page in the sheet, like Calendar's: open it, pick, come back
async function setRepeat(p, fq) {
await p.click("#add-reprow");
await p.click('[data-pfq="' + fq + '"]');
}
const back = (p) => p.click('#add-form section:not([hidden]) .w-aback');
const catValue = (p) => p.$("#add-cat").then((n) => (n ? p.inputValue("#add-cat") : text(p, "#add-catv")));
// The date's calendar: open it, step to the month, pick the day
async function pickDate(p, iso) {
const trigger = (await p.$("#add-pickb")) ? "#add-pickb" : "#add-datecell";
if (await p.getAttribute(trigger, "aria-expanded") !== "true") await p.click(trigger);
for (let i = 0; i < 36 && !(await p.$('#add-cal [data-cd="' + iso + '"]')); i++) {
const first = await p.getAttribute("#add-cal [data-cd]", "data-cd");
await p.click('#add-cal [data-cm="' + (iso < first ? -1 : 1) + '"]');
}
await p.click('#add-cal [data-cd="' + iso + '"]');
}
const clearToast = (p) => p.evaluate(() => { document.getElementById("toast").classList.remove("on"); document.getElementById("toast-t").textContent = ""; });
const shot = async (page, name) => { if (SHOTS) await page.waitForTimeout(450), await page.screenshot({ path: SHOTS + "/" + name + ".png" }); };
const today = (page) => core(page, (c) => c.TODAY);
const addDays = (d, n) => new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8)) + n * 864e5).toISOString().slice(0, 10);
/* ======================================================================= */
const page = await newPage();
const TODAY = await today(page);
section("Fast path: amount, Enter, three letters, Enter");
await page.click(".w-hadd").catch(() => page.keyboard.press("n"));
await page.waitForSelector("#add-form");
check(await focused(page) === "add-amt", "amount has focus on open");
check(await page.$eval("#add-form", (f) => f.getAttribute("role") === "dialog" && f.getAttribute("aria-modal") === "true"), "sheet is a modal dialog");
check(/Enter an amount/.test(await text(page, "#add-go")), "button says Enter an amount", await text(page, "#add-go"));
await page.keyboard.type("12.40+8.15");
check(/20\.55/.test(await text(page, "#add-calc")), "sum shown before saving", await text(page, "#add-calc"));
check(await page.$eval("#add-amt", (i) => i.scrollWidth <= i.clientWidth), "the whole amount shows, 1s included");
check(/Pick a category/.test(await text(page, "#add-go")), "button says what is missing: the category, as the name is optional");
await page.keyboard.press("Enter");
check(await focused(page) === "add-name", "Enter moves to the name");
check(await page.inputValue("#add-amt") === "20.55", "sum becomes its total", await page.inputValue("#add-amt"));
await page.keyboard.type("mer");
check(await visible(page, "#add-sugg"), "payee suggestions open");
check(await page.getAttribute("#add-name", "aria-expanded") === "true" && await page.getAttribute("#add-name", "aria-activedescendant") === "add-sg-0", "combobox announces the listbox");
check(await page.$$eval("#add-sugg [role=option]", (l) => l.length) >= 1, "options in a listbox");
await page.keyboard.press("Enter");
check(/^Mercadona/.test(await page.inputValue("#add-name")), "Enter picks the payee", await page.inputValue("#add-name"));
check(await page.inputValue("#add-cat") === "Groceries", "payee fills its category");
check(await page.$eval("#add-name", (n) => n.placeholder) === "What was it for?", "the question stays in the field");
check(await text(page, "#lbl-name") === "Merchant", "expense: the name is labelled Merchant");
const whoPicked = await page.$eval("#add-who [aria-checked=true]", (n) => n.dataset.aw);
check(!!whoPicked, "payee fills its person", whoPicked);
await shot(page, "desk-filled");
let saved = await saveWith(page);
check(/^Mercadona/.test(saved.name) && saved.amount === "20.55" && saved.category === "Groceries", "Ctrl+Enter adds", saved);
check(!(await isOpen(page)), "sheet closes after adding");
check(/Added Mercadona/.test(await toastText(page, /Added Mercadona/)), "toast says what was added");
check(await core(page, (c, id) => c.state.tx.some((x) => x.id === id), saved.id), "ledger in the browser has it");
await page.click("#toast-undo");
await toastText(page, /Undone/);
check((await api(page, "GET", "/transactions/id/" + saved.id)).data.length === 0, "Undo removes what was just added");
check(!(await core(page, (c, id) => c.state.tx.some((x) => x.id === id), saved.id)), "and from the browser's ledger");
section("Keys: the category combobox, radio groups, focus trap, Escape");
await open(page);
await page.keyboard.type("4");
await page.focus("#add-cat");
check(await visible(page, "#add-catlist") && await page.getAttribute("#add-cat", "aria-expanded") === "true" && await page.getAttribute("#add-cat", "aria-activedescendant") === "add-co-0", "Category opens its list on focus");
check(await page.$$eval("#add-catlist [role=option]", (l) => l.length) >= 5, "every category is offered");
await page.keyboard.press("ArrowDown");
const second = await text(page, "#add-co-1 b");
await page.keyboard.press("Enter");
check(await page.inputValue("#add-cat") === second && !(await visible(page, "#add-catlist")) && await focused(page) === "add-cat", "arrows and Enter pick a category", second);
await page.keyboard.type("gro");
check(await text(page, "#add-co-0 b") === "Groceries", "typing filters the list");
await page.keyboard.press("Tab");
check(await page.inputValue("#add-cat") === "Groceries" && await focused(page) !== "add-cat", "Tab takes the match and moves on");
await page.focus("#add-cat");
await page.keyboard.type("Board games");
check(/New category/.test(await text(page, "#add-catlist")), "a name that matches nothing is offered as a new category");
await page.keyboard.press("Escape");
check(await isOpen(page) && !(await visible(page, "#add-catlist")) && await page.inputValue("#add-cat") === "Groceries", "Escape closes the list and keeps the category");
await page.keyboard.type("Board games");
await page.keyboard.press("Enter");
check(await page.inputValue("#add-cat") === "Board games", "Enter takes the new category");
await page.focus('#add-who [role="radio"][tabindex="0"]');
const w0 = await page.$eval("#add-who [aria-checked=true]", (n) => n.dataset.aw);
await page.keyboard.press("ArrowRight");
const w1 = await page.$eval("#add-who [aria-checked=true]", (n) => n.dataset.aw);
check(w0 !== w1, "arrows choose within Who", [w0, w1]);
// focus trap
await page.focus("#add-go");
await page.keyboard.press("Tab");
check(await page.evaluate(() => document.getElementById("add-form").contains(document.activeElement)), "Tab stays inside the sheet");
await page.focus("[data-at=expense]");
await page.keyboard.press("Shift+Tab");
check(await focused(page) === "add-go", "Shift+Tab from the first goes to the last", await focused(page));
await page.keyboard.press("Escape");
check(await visible(page, "#dlg .w-dialog"), "Escape asks before discarding");
check(/Discard this expense/.test(await text(page, "#dlg h5")), "asks: Discard this expense?");
await page.click("#dlg [data-dlg-x]");
check(await isOpen(page), "Keep editing keeps the sheet");
await page.keyboard.press("Escape");
await page.click("#dlg [data-dlg-ok]");
check(!(await isOpen(page)), "Discard closes it");
// opening and closing without changes asks nothing; focus returns
await page.focus(".w-hadd");
await page.keyboard.press("Enter");
await page.waitForSelector("#add-form");
await page.click("[data-close]");
check(!(await isOpen(page)) && !(await visible(page, "#dlg .w-dialog")), "× closes at once when nothing changed");
check(await page.evaluate(() => document.activeElement.classList.contains("w-hadd")), "focus returns where it was");
await open(page);
await page.keyboard.type("9");
await page.mouse.click(5, 450);
check(await visible(page, "#dlg .w-dialog"), "click outside asks too");
await page.click("#dlg [data-dlg-ok]");
section("Errors under their field");
await open(page);
await page.keyboard.type("abc");
check(/numbers, \+ and −/.test(await text(page, "#add-aerr")) && await visible(page, "#add-aerr"), "letters: error under the amount");
check(await page.getAttribute("#add-amt", "aria-invalid") === "true", "amount marked invalid");
await page.fill("#add-amt", "5-10");
check(/more than zero/.test(await text(page, "#add-aerr")), "negative total: error");
await page.fill("#add-amt", "");
await page.focus("#add-name");
await page.click("#add-go", { force: true });
check(await focused(page) === "add-amt", "main button takes you to what is missing");
await page.fill("#add-amt", "3");
await page.click("#add-go", { force: true });
check(await focused(page) === "add-cat" && !(await visible(page, "#add-nerr")), "then to the category, past the empty name");
await page.fill("#add-name", "Errand");
// a date in the future needs Repeat
await pickCat(page, "Food");
await page.click("#add-pickb");
// On the last day of a month, tomorrow is not in the grid at all: the next month cannot be opened
const tomorrow = '#add-cal [data-cd="' + addDays(TODAY, 1) + '"]';
const tomorrowOff = (await page.$(tomorrow)) ? await page.isDisabled(tomorrow) : await page.isDisabled('#add-cal [data-cm="1"]');
check(tomorrowOff && !(await page.isDisabled('#add-cal [data-cd="' + TODAY + '"]')), "without Repeat, days after today cannot be picked");
await setRepeat(page, "monthly"); await back(page);
await pickDate(page, addDays(TODAY, 3));
await setRepeat(page, "never"); await back(page);
check(/turn on Repeat/.test(await text(page, "#add-derr")), "future date without Repeat: error under the date");
await page.click('[data-ad="today"]');
check(!(await visible(page, "#add-derr")), "error goes when fixed");
await page.focus("#add-name");
saved = await saveWith(page, "Enter");
check(saved.name === "Errand" && saved.category === "Food", "Enter on the name adds", saved.name);
await toastText(page, /Added/);
section("Duplicate warning");
await open(page);
await page.keyboard.type("7");
await pickCat(page, "Food");
check(await page.locator("#add-budget").count() === 0, "no budget hint under the category");
await page.fill("#add-name", "Dup check");
await page.keyboard.press("Escape"); // closes suggestions if any
if (await visible(page, "#dlg .w-dialog")) await page.click("#dlg [data-dlg-x]");
saved = await clickSave(page);
await toastText(page, /Added Dup check/);
await open(page);
await page.keyboard.type("7");
await page.fill("#add-name", "Dup check");
await pickCat(page, "Food");
check(await visible(page, "#add-dup") && /Already added\?/.test(await text(page, "#add-dup")), "same name, amount and day: duplicate warning");
await page.click('[data-ad="yesterday"]');
check(!(await visible(page, "#add-dup")), "no warning on another day");
await page.keyboard.press("Escape");
await page.click("#dlg [data-dlg-ok]");
section("Who, shares and the spare cent");
async function sharedSave(amount, setup) {
await open(page);
await page.keyboard.type(amount);
await page.fill("#add-name", "Shared lunch");
await pickCat(page, "Food");
await page.click('[data-aw="shared"]');
if (setup) await setup();
return clickSave(page);
}
saved = await sharedSave("10.01", async () => { await page.selectOption("#add-bysel", "1"); });
check(JSON.stringify(saved.shares) === JSON.stringify({ 1: 501, 2: 500 }) && saved.paid_by === 1, "10.01 paid by Alex: 5.01 / 5.00", saved.shares);
await toastText(page, /Added/);
saved = await sharedSave("10.01", async () => { await page.selectOption("#add-bysel", "2"); });
check(saved.shares["1"] === 500 && saved.shares["2"] === 501 && saved.paid_by === 2, "paid by Ana: the spare cent is Ana's", saved.shares);
await toastText(page, /Added/);
saved = await sharedSave("0.01", async () => { await page.selectOption("#add-bysel", "1"); });
check(saved.shares["1"] === 1 && !saved.shares["2"], "0.01: the one cent is the payer's", saved.shares);
await toastText(page, /Added/);
// by income
saved = await sharedSave("100", async () => {
await page.selectOption("#add-smsel", "income");
const lab = await page.$eval('#add-smsel option[value="income"]', (n) => n.textContent);
check(/By income \d+\/\d+/.test(lab), "By income shows the split", lab);
check(/^By income: /.test(await text(page, "#add-splitnote")), "under the rows: each person's part by income", await text(page, "#add-splitnote"));
});
const [ia, ib] = [saved.shares["1"], saved.shares["2"]];
const expectA = await core(page, (c) => {
const [y, m] = c.ym(c.TODAY), q = c.ymAdd(y, m, -3), from = c.ds(q[0], q[1], 1);
const a = c.totals(from, c.TODAY, "1").inc, b = c.totals(from, c.TODAY, "2").inc;
return Math.floor((10000 * a) / (a + b));
});
check(ia + ib === 10000 && Math.abs(ia - expectA) <= 1, "By income: shares from income, adding up", { ia, ib, expectA });
await toastText(page, /Added/);
// custom: a row per person, the first typed, the other gets the rest
saved = await sharedSave("50", async () => {
await page.selectOption("#add-smsel", "custom");
check(await visible(page, "#add-split-a") && await visible(page, "#sp-v1"), "Custom: a row per person");
await page.fill("#add-split-a", "15");
check(/35\.00/.test(await text(page, "#sp-v1")) && /30%/.test(await text(page, "#sp-p0")), "typing one part sets the other", await text(page, "#sp-v1"));
});
check(saved.shares["1"] === 1500 && saved.shares["2"] === 3500, "Custom: 15.00 / 35.00", saved.shares);
await toastText(page, /Added/);
saved = await sharedSave("50", async () => {
await page.selectOption("#add-smsel", "custom");
await page.fill("#add-split-a", "12.34");
check(/37\.66/.test(await text(page, "#sp-v1")), "typed part: the other gets the rest", await text(page, "#sp-v1"));
});
check(saved.shares["1"] === 1234 && saved.shares["2"] === 3766, "Custom typed: exact cents", saved.shares);
await toastText(page, /Added/);
section("No name");
await open(page, {});
check(await page.getAttribute("#add-name", "aria-required") === "false", "the name is not required");
await page.keyboard.type("4.20");
await pickCat(page, "Food");
check(await text(page, "#add-go") === "Add Expense", "an amount and a category are enough", await text(page, "#add-go"));
saved = await clickSave(page);
check(saved.name === "" && saved.category === "Food", "it is saved without a name, not with the category as one", saved);
await open(page, {});
await page.keyboard.type("4.20");
await pickCat(page, "Food");
check(await visible(page, "#add-dup") && /Food, /.test(await text(page, "#add-dup")), "the same again on the same day: Already added?, by its category", await text(page, "#add-dup"));
await setRepeat(page, "monthly"); await back(page);
check(/Enter what it was for/.test(await text(page, "#add-go")) && await page.$eval("#add-name", (n) => n.placeholder) === "What was it for?" && await page.getAttribute("#add-name", "aria-required") === "true", "Repeat: the name is required, and the button asks for it");
await page.click("#add-go", { force: true });
check(await focused(page) === "add-name" && /Enter what it was for/.test(await text(page, "#add-nerr")) && await page.getAttribute("#add-name", "aria-invalid") === "true", "the main button goes to the name, with the error under it");
await page.fill("#add-name", "Errand");
check(!(await visible(page, "#add-nerr")), "the error goes once a name is typed");
await page.click('#add-form section:not([hidden]) [data-close]');
if (await visible(page, "#dlg .w-dialog")) await page.click("#dlg [data-dlg-ok]");
await page.waitForTimeout(300);
section("Income");
await open(page, { type: "income" });
check(!(await visible(page, "#add-catrow")), "income hides the Category row");
check(await text(page, "#lbl-who") === "Who", "income says Who too");
await page.keyboard.type("2450");
check(await text(page, "#add-go") === "Add Income", "income: the source is optional too", await text(page, "#add-go"));
check(await text(page, "#lbl-name") === "Source", "income: the name is labelled Source");
await page.fill("#add-name", "Side job");
await page.click('[data-aw="shared"]');
check(!(await visible(page, "#add-byrow")), "income has no Paid by");
check(await text(page, "#add-go") === "Add Income", "button: Add Income");
saved = await clickSave(page);
check(saved.type === "income" && saved.category === "Income" && saved.paid_by === null, "saved as income", { t: saved.type, c: saved.category, b: saved.paid_by });
await toastText(page, /Added/);
await open(page);
await page.click("[data-at=income]");
check(!(await visible(page, "#add-catrow")), "switching to Income hides Category");
await page.click("[data-at=expense]");
check(await visible(page, "#add-catrow"), "and back");
await page.click("[data-close]");
section("Date, tags and note");
await open(page);
await page.keyboard.type("4.20");
await page.fill("#add-name", "Road snack");
await pickCat(page, "Food");
await page.click('[data-ad="yesterday"]');
check(await visible(page, "#add-tag") && await visible(page, "#add-note"), "with an amount, Tags and Note join the second group");
const tagsNow = () => page.$$eval("#add-tags [data-tg]", (l) => l.map((n) => n.dataset.tg).join());
check(await page.getAttribute("#add-tag", "placeholder") === "Add a tag", "a short placeholder: Add a tag");
const tagW = async () => Math.round((await page.locator("#add-tag").boundingBox()).width);
const tw0 = await tagW();
await page.click("#add-tag");
check(await tagW() === tw0, "the tag field keeps its width when it gets the focus", [tw0, await tagW()]);
await page.keyboard.type("Road Trip ");
check(await tagsNow() === "#road,#trip" && await page.inputValue("#add-tag") === "", "a space ends a tag: “Road Trip ” is #road and #trip", await tagsNow());
check(await page.getAttribute("#add-tag", "placeholder") === "+ tag", "then the placeholder says + tag");
await page.keyboard.type("coffee,");
check(await tagsNow() === "#road,#trip,#coffee", "a comma ends a tag too", await tagsNow());
await page.press("#add-tag", "Backspace");
check(await tagsNow() === "#road,#trip", "Backspace on an empty tag field removes the last tag", await tagsNow());
check(await page.$$eval("#add-taglist option", (l) => l.some((o) => o.value === "#coffee")), "tag suggestions from the household's tags");
await page.keyboard.type("hol");
await page.click('#add-tags [data-tg="#trip"]');
check(await tagsNow() === "#road" && await page.inputValue("#add-tag") === "hol" && await page.evaluate(() => document.activeElement.id) === "add-tag",
"× removes a tag and leaves the focus, and the half-typed tag, in the field", [await tagsNow(), await page.inputValue("#add-tag")]);
await page.keyboard.type("iday");
await page.fill("#add-note", "Paid in cash");
saved = await clickSave(page);
check(saved.date === addDays(TODAY, -1), "Yesterday", saved.date);
check(saved.tags.join() === "road,holiday", "tags saved, including one still typed", saved.tags);
check(saved.note === "Paid in cash", "note saved");
await toastText(page, /Added/);
await open(page);
await page.keyboard.type("3");
await page.fill("#add-name", "Old receipt");
await pickCat(page, "Food");
await page.click("#add-pickb");
check(await visible(page, "#add-cal") && await page.evaluate(() => document.activeElement.dataset.cd) === TODAY, "Other date opens the calendar on today, focused");
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowUp");
check(await page.evaluate(() => document.activeElement.dataset.cd) === addDays(TODAY, -8), "arrows move by a day and a week");
await page.keyboard.press("PageUp");
check(await page.getAttribute("#add-cal [data-cd]", "data-cd") < addDays(TODAY, -8).slice(0, 8) + "01", "Page Up goes to the month before");
await page.keyboard.press("Escape");
check(!(await visible(page, "#add-cal")) && await visible(page, "#add-modal") && await page.evaluate(() => document.activeElement.dataset.ad) === "today", "Escape closes the calendar, not the sheet");
await pickDate(page, addDays(TODAY, -40));
check(!(await visible(page, "#add-cal")) && await page.getAttribute("#add-pickb", "aria-pressed") === "true" && await focused(page) === "add-pickb", "Other date shows the day picked, the calendar closed");
saved = await clickSave(page);
check(saved.date === addDays(TODAY, -40), "other date saved", saved.date);
await toastText(page, /Added/);
section("Add another (Shift+Cmd+Enter)");
await open(page);
await page.keyboard.type("2");
await page.fill("#add-name", "Kiosk");
await pickCat(page, "Food");
await page.click('[data-aw="2"]');
await page.click('[data-ad="yesterday"]');
saved = await saveWith(page, "Shift+Control+Enter");
check(saved.person_id === 2, "first one saved");
check(await isOpen(page), "sheet stays open");
check(await page.inputValue("#add-amt") === "" && await focused(page) === "add-amt", "amount cleared and focused");
check(await page.getAttribute('[data-aw="2"]', "aria-checked") === "true" && await page.getAttribute('[data-ad="yesterday"]', "aria-pressed") === "true", "keeps the date and the person");
await page.keyboard.type("3");
await page.fill("#add-name", "Kiosk");
await pickCat(page, "Food");
saved = await clickSave(page);
check(saved.person_id === 2 && saved.date === addDays(TODAY, -1), "second one has the same person and date");
await toastText(page, /Added/);
section("Repeat");
async function ruleSave(setup) {
await open(page);
await setup();
await clearToast(page);
const resp = page.waitForResponse((r) => r.url().endsWith("/api/recurring") && r.request().method() === "POST");
await page.click("#add-go");
const rule = await (await resp).json();
await toastText(page, /Added/);
const made = (await core(page, (c, id) => c.state.tx.filter((x) => x.rid === id).map((x) => x.d).sort(), rule.id));
return { rule, made };
}
let r = await ruleSave(async () => {
await page.keyboard.type("15.99");
await page.fill("#add-name", "Streamy");
await pickCat(page, "Entertainment");
await page.click("#add-reprow");
check(await visible(page, '[data-pstep="repeat"]') && !(await visible(page, '[data-pstep="main"]')), "Repeat opens as a page in the sheet");
check(await page.getAttribute('[data-pfq="never"]', "aria-checked") === "true", "starts at Never");
await page.click('[data-pfq="monthly"]');
check(await page.getAttribute('[data-pfq="monthly"]', "aria-checked") === "true" && await page.inputValue("#add-endsel") === "never", "Every month, never ending");
check(/^Next: .+, .+, .+/.test(await text(page, "#add-next")), "the next three dates", await text(page, "#add-next"));
await back(page);
check(await focused(page) === "add-reprow" && await text(page, "#add-repv") === "Every month", "back at Repeat, showing the choice", await text(page, "#add-repv"));
check(await text(page, "#lbl-date") === "Starts", "Date becomes Starts");
});
check(r.rule.frequency === "monthly" && r.rule.interval === 1 && r.rule.endDate === null && r.rule.startDate === TODAY, "monthly rule created", r.rule);
check(r.made.length === 1 && r.made[0] === TODAY, "today's one is added by the server", r.made);
await page.click("#toast-undo");
await toastText(page, /Undone/);
check((await api(page, "GET", "/recurring/" + r.rule.id)).data.length === 0 && !(await core(page, (c, id) => c.state.tx.some((x) => x.rid === id), r.rule.id)), "Undo removes the rule and what it made");
const start2 = addDays(TODAY, -30);
r = await ruleSave(async () => {
await page.keyboard.type("32");
await page.fill("#add-name", "Swim class");
await pickCat(page, "Healthcare");
await setRepeat(page, "monthly"); await back(page);
await pickDate(page, start2);
await setRepeat(page, "biweekly");
await page.selectOption("#add-endsel", "count");
await page.fill("#add-count", "4");
const sum = await text(page, "#add-rsum");
check(/2 more up to today are added too/.test(sum), "every 2 weeks, 4 times: the past dates are named", sum);
check(/Every 2 weeks, 4 times/.test(await page.evaluate(async () => { document.querySelector('#add-form section:not([hidden]) .w-aback').click(); return document.getElementById("add-repv").textContent; })), "the row says Every 2 weeks, 4 times");
});
check(r.rule.frequency === "weekly" && r.rule.interval === 2 && r.rule.startDate === start2 && r.rule.endDate === addDays(start2, 42), "after 4 times becomes an end date", [r.rule.startDate, r.rule.endDate]);
check(JSON.stringify(r.made) === JSON.stringify([start2, addDays(start2, 14), addDays(start2, 28)]), "past dates added by the server", r.made);
const swimRule = r.rule;
r = await ruleSave(async () => {
await page.keyboard.type("5");
await page.fill("#add-name", "Plant food");
await pickCat(page, "Shopping");
await setRepeat(page, "custom");
check(await focused(page) === "add-every", "Custom focuses Every");
await page.fill("#add-every", "3");
await page.selectOption("#add-unit", "daily");
await page.selectOption("#add-endsel", "until");
await page.fill("#add-until", TODAY);
check(/after/.test(await text(page, "#add-rerr")), "last date must be after the start", await text(page, "#add-rerr"));
check(/Fix the highlighted field/.test(await text(page, "#add-go")), "button names the problem");
await page.fill("#add-until", addDays(TODAY, 30));
await back(page);
check(/^Every 3 days/.test(await text(page, "#add-repv")), "every 3 days on the row", await text(page, "#add-repv"));
});
check(r.rule.frequency === "daily" && r.rule.interval === 3 && r.rule.endDate === addDays(TODAY, 30), "every 3 days until a date", r.rule);
r = await ruleSave(async () => {
await page.keyboard.type("49");
await page.fill("#add-name", "Domain name");
await pickCat(page, "Utilities");
await setRepeat(page, "yearly");
check(await page.getAttribute('[data-pfq="yearly"]', "aria-checked") === "true", "Every year checked");
await back(page);
});
check(r.rule.frequency === "yearly" && r.rule.interval === 1, "yearly rule");
r = await ruleSave(async () => {
await page.keyboard.type("1.50");
await page.fill("#add-name", "Bread");
await pickCat(page, "Groceries");
await setRepeat(page, "daily");
await page.selectOption("#add-endsel", "count");
await page.fill("#add-count", "5");
await back(page);
});
check(r.rule.frequency === "daily" && r.rule.endDate === addDays(TODAY, 4), "daily, 5 times", r.rule.endDate);
r = await ruleSave(async () => {
await page.keyboard.type("9");
await page.fill("#add-name", "Weekly box");
await pickCat(page, "Groceries");
await setRepeat(page, "weekly");
await back(page);
});
check(r.rule.frequency === "weekly" && r.rule.interval === 1, "weekly rule");
// openAdd({repeat:true})
await open(page, { repeat: true });
check(await text(page, "#add-repv") === "Every month", "openAdd({repeat:true}) opens with Repeat on", await text(page, "#add-repv"));
await page.click("[data-close]");
// "Make it a rule" from Recurring: everything prefilled
await open(page, { repeat: true, name: "Power", amount: 61.5, category: "Utilities", person: null, date: addDays(TODAY, 5), type: "expense", freq: "monthly", every: 2, about: true });
check(await page.inputValue("#add-name") === "Power" && await page.inputValue("#add-amt") === "61.50" && await page.inputValue("#add-cat") === "Utilities"
&& await page.getAttribute('[data-aw="shared"]', "aria-checked") === "true" && /^Every 2 months/.test(await text(page, "#add-repv")), "prefill: name, amount, category, person, every 2 months", await text(page, "#add-repv"));
await clearToast(page);
let pre = page.waitForResponse((x) => x.url().endsWith("/api/recurring") && x.request().method() === "POST");
await page.click("#add-go");
pre = await (await pre).json();
check(pre.about === true && pre.interval === 2 && pre.frequency === "monthly" && pre.person_id === null && pre.startDate === addDays(TODAY, 5), "rule saved with about, interval and a future start", pre);
check(!(await core(page, (c, id) => c.state.tx.some((x) => x.rid === id), pre.id)), "a future start adds nothing yet");
await toastText(page, /Added Power/);
await open(page, { name: "Corner shop", amount: "3,20", category: "Groceries", person: "2", date: addDays(TODAY, -2) });
check(await page.inputValue("#add-name") === "Corner shop" && await page.getAttribute('[data-aw="2"]', "aria-checked") === "true" && await page.getAttribute("#add-pickb", "aria-pressed") === "true" && /^3[.,]20$/.test(await page.inputValue("#add-amt")) && await text(page, "#add-go") === "Add Expense", "prefill for a plain transaction", [await page.inputValue("#add-amt"), await text(page, "#add-go")]);
await page.keyboard.press("Escape");
if (await visible(page, "#dlg .w-dialog")) await page.click("#dlg [data-dlg-ok]");
section("Edit a transaction, delete and Undo");
const victim = (await core(page, (c, d) => c.state.tx.find((x) => x.type === "expense" && !x.rec && !x.gr && x.p !== null && x.d < d), TODAY));
await open(page, { txId: victim.id });
check(/Edit expense/.test(await text(page, "#add-title")) && await text(page, "#add-htitle") === "Transaction" && !(await visible(page, "#add-editing")), "edit: Cancel · Transaction · Save, no Editing pill");
check(await page.inputValue("#add-name") === victim.n, "fields filled");
check(await visible(page, "#add-del") && await text(page, "#add-del") === "Delete transaction", "edit shows Delete transaction as the last row");
check(await focused(page) === "add-form", "editing selects nothing");
await page.fill("#add-amt", "77.70");
check(await text(page, "#add-go") === "Save" && await page.getAttribute("#add-go", "aria-disabled") === "false", "Save at the top");
saved = await clickSave(page, /\/api\/transactions\/[0-9a-f-]+$/);
check(saved.id === victim.id && saved.amount === "77.70", "PUT saved the change");
await toastText(page, /Saved/);
await page.click("#toast-undo");
await toastText(page, /Undone/);
check((await api(page, "GET", "/transactions/id/" + victim.id)).data[0].amount === victim.a.toFixed(2), "Undo restores the previous values");
await open(page, { txId: victim.id });
await page.click("#add-amt");
await page.keyboard.type("1");
await page.keyboard.press("Escape");
check(/Discard your changes/.test(await text(page, "#dlg h5")), "edit asks: Discard your changes?");
await page.click("#dlg [data-dlg-x]");
const delResp = page.waitForResponse((x) => x.request().method() === "DELETE");
await page.click("#add-del");
await delResp;
await toastText(page, /Deleted/);
check(!(await isOpen(page)) && (await api(page, "GET", "/transactions/id/" + victim.id)).data.length === 0, "Delete removes it");
await page.click("#toast-undo");
await toastText(page, /Undone/);
check((await api(page, "GET", "/transactions/id/" + victim.id)).data.length === 1 && await core(page, (c, id) => c.state.tx.some((x) => x.id === id), victim.id), "Undo puts it back with its id");
section("A transaction made by a rule, and the rule");
const madeTx = await core(page, (c, id) => c.state.tx.find((x) => x.rid === id), swimRule.id);
await open(page, { txId: madeTx.id });
check(/Comes from a rule\. Saving changes only this one\./.test(await text(page, "#add-rep-l")), "says it changes only itself");
check(!(await visible(page, "#add-reprow")), "no Repeat row on it");
await page.fill("#add-amt", "35");
saved = await clickSave(page, /\/api\/transactions\/[0-9a-f-]+$/);
check(saved.recurringID === swimRule.id && saved.amount === "35.00", "saved only this one, still linked to its rule");
await toastText(page, /Saved/);
check(Number((await api(page, "GET", "/recurring/" + swimRule.id)).data[0].amount) === 32, "the rule is unchanged");
await open(page, { txId: madeTx.id });
await page.click("[data-rule-edit]");
check(/Edit recurring expense/.test(await text(page, "#add-title")), "links to the rule, in the same sheet");
await page.fill("#add-amt", "40");
await page.click("#add-go");
await page.waitForSelector("#dlg .w-dialog");
check(/From the next charge/.test(await text(page, "#dlg")) && /All, including past ones/.test(await text(page, "#dlg")), "asks: from the next charge, or all");
let putResp = page.waitForResponse((x) => /\/api\/recurring\//.test(x.url()) && x.request().method() === "PUT");
await page.click("#dlg [data-dlg-ok]");
let put = await (await putResp).json();
await toastText(page, /Saved/);
const later = (await api(page, "GET", "/recurring")).data;
const oldRule = later.find((x) => x.id === swimRule.id), newRule = later.find((x) => x.id === put.id);
check(put.id !== swimRule.id && Number(newRule.amount) === 40 && oldRule.endDate < newRule.startDate, "From the next charge: a new rule from the next date", { old: oldRule.endDate, start: newRule.startDate });
check((await api(page, "GET", "/transactions/id/" + madeTx.id)).data[0].amount === "35.00", "past ones stay as they are");
await page.click("#toast-undo");
await toastText(page, /Undone/);
const afterUndo = (await api(page, "GET", "/recurring")).data;
check(!afterUndo.some((x) => x.id === put.id) && afterUndo.find((x) => x.id === swimRule.id).endDate === swimRule.endDate, "Undo brings the old rule back as it was");
// all
await open(page, { ruleId: swimRule.id });
check(await page.inputValue("#add-amt") === "32.00" && /until/.test(await text(page, "#add-repv")) && await text(page, "#add-htitle") === "Bill", "rule fields filled", await text(page, "#add-repv"));
await page.fill("#add-amt", "33");
await page.click("#add-go");
await page.waitForSelector("#dlg .w-dialog");
await page.check('input[name="add-apply"][value="all"]');
putResp = page.waitForResponse((x) => /\/api\/recurring\//.test(x.url()) && x.request().method() === "PUT");
await page.click("#dlg [data-dlg-ok]");
put = await (await putResp).json();
await toastText(page, /Saved/);
const amounts = await core(page, (c, id) => c.state.tx.filter((x) => x.rid === id).map((x) => x.a), swimRule.id);
check(put.id === swimRule.id && amounts.length === 3 && amounts.every((a) => a === 33), "All, including past ones: every one it made changes", amounts);
// cancel the question keeps the sheet
await open(page, { ruleId: swimRule.id });
await page.fill("#add-amt", "34");
await page.click("#add-go");
await page.waitForSelector("#dlg .w-dialog");
await page.click("#dlg [data-dlg-x]");
check(await isOpen(page), "Cancel on the question keeps the sheet open");
await page.keyboard.press("Escape");
await page.click("#dlg [data-dlg-ok]");
section("Split a receipt");
await open(page);
await page.keyboard.type("86.40");
await page.fill("#add-name", "Hypermarket");
await pickCat(page, "Groceries");
await page.click("#add-cat");
check(/Split across categories/.test(await text(page, "#add-catlist [role=option]:last-child")), "Split is the last choice in the category list");
await page.click("#add-catlist [role=option]:last-child");
check(await visible(page, "#add-parts") && !(await visible(page, "#add-catrow")), "Split shows the parts");
check(!(await visible(page, "#add-reprow")), "no Repeat on a split receipt");
await page.fill('[data-pa="0"]', "61.20");
await page.selectOption('[data-pc="1"]', "Shopping");
await page.fill('[data-pa="1"]', "18.95");
check(/Left to assign\s*6\.25/.test(await text(page, "#add-left")), "remaining shown live", await text(page, "#add-left"));
check(/Assign 6\.25.*more/.test(await text(page, "#add-go")), "button: Assign … more", await text(page, "#add-go"));
await page.fill('[data-pa="1"]', "30");
check(/Take .* off the parts/.test(await text(page, "#add-go")) && /Too much/.test(await text(page, "#add-left")), "too much is named too");
await page.fill('[data-pa="1"]', "18.95");
await page.click("[data-addpart]");
await page.selectOption('[data-pc="2"]', "Utilities");
check(/Left to assign\s*0\.00/.test(await text(page, "#add-left")), "an empty part takes what is left");
check(await page.getAttribute('[data-pa="2"]', "placeholder") === "6.25", "and shows it");
let gResp = page.waitForResponse((x) => x.url().endsWith("/api/transactions/group"));
await page.click("#add-go");
let group = await (await gResp).json();
check(group.length === 3 && new Set(group.map((x) => x.group_id)).size === 1 && group.map((x) => x.amount).join() === "61.20,18.95,6.25", "POST /transactions/group with three parts", group.map((x) => x.amount));
await toastText(page, /split in 3/);
// edit the group
await open(page, { txId: group[1].id });
check(await page.$$eval("[data-pa]", (l) => l.length) === 3 && /86\.40/.test(await page.inputValue("#add-amt")), "editing a part edits its group");
await page.click('[data-rmpart="2"]');
await page.fill('[data-pa="1"]', "");
const groupSaves = [];
page.on("response", (x) => { if (/\/api\/transactions/.test(x.url()) && x.request().method() !== "GET") groupSaves.push(x.request().method()); });
await page.click("#add-go");
await toastText(page, /Saved Hypermarket/);
const g2 = (await api(page, "GET", "/transactions/search?text=Hypermarket")).data.filter((x) => x.group_id === group[0].group_id).map((x) => x.amount).sort();
check(JSON.stringify(g2) === JSON.stringify(["25.20", "61.20"]), "edited group: two parts, the rest moved to the empty one", g2);
await page.click("#toast-undo");
await toastText(page, /Undone/);
const g3 = (await api(page, "GET", "/transactions/search?text=Hypermarket")).data.filter((x) => x.group_id === group[0].group_id).map((x) => x.amount).sort();
check(JSON.stringify(g3) === JSON.stringify(["18.95", "6.25", "61.20"]), "Undo restores the three parts", g3);
// delete the group
await open(page, { txId: group[0].id });
await page.click("#add-del");
await toastText(page, /Deleted/);
check((await api(page, "GET", "/transactions/search?text=Hypermarket")).data.length === 0, "Delete removes the whole receipt");
section("Offline queue");
allowNetErrors = true;
await page.route("**/api/transactions", (route) => (route.request().method() === "POST" ? route.abort() : route.continue()));
await open(page);
await page.keyboard.type("6.66");
await page.fill("#add-name", "Offline bakery");
await pickCat(page, "Food");
await page.click("#add-go");
check(/No connection/.test(await toastText(page, /No connection/)), "save while offline: kept on this device");
check(!(await isOpen(page)), "sheet closes");
check(await visible(page, "#add-queue") && /1 waiting to send/.test(await text(page, "#add-queue")), "Waiting to send note shown");
check(await core(page, (c) => c.state.tx.some((x) => x.pending && x.n === "Offline bakery")), "counted in the ledger as pending");
check(await page.evaluate(() => JSON.parse(localStorage.getItem("wally.queue")).length) === 1, "kept in localStorage");
// survives a reload
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector("#app:not([hidden])");
await page.waitForTimeout(500);
check(await visible(page, "#add-queue"), "still waiting after a reload");
// edit it while it waits
const pendingTx = await core(page, (c) => c.state.tx.find((x) => x.pending));
await open(page, { txId: pendingTx.id });
check(/Waiting to send/.test(await text(page, "#add-editing")), "a waiting one says so in the sheet");
await page.fill("#add-amt", "6.70");
await page.click("#add-go");
await toastText(page, /still waiting/);
check(await page.evaluate(() => JSON.parse(localStorage.getItem("wally.queue"))[0].body.amount) === "6.70", "editing changes it where it waits");
await page.unroute("**/api/transactions");
const sent = page.waitForResponse((x) => x.url().endsWith("/api/transactions") && x.request().method() === "POST");
await page.evaluate(() => window.dispatchEvent(new Event("online")));
const sentTx = await (await sent).json();
await toastText(page, /Sent 1 transaction/);
check(sentTx.name === "Offline bakery" && sentTx.amount === "6.70", "sent when back online");
check(!(await visible(page, "#add-queue")) && !(await core(page, (c) => c.state.tx.some((x) => x.pending))), "note gone, no pending rows left");
check(await core(page, (c, id) => c.state.tx.some((x) => x.id === id), sentTx.id), "the sent one is in the ledger");
allowNetErrors = false;
section("Dark, widths and screenshots");
for (const [w, h, theme] of [[1440, 900, "dark"], [900, 900, "light"], [1100, 800, "light"]]) {
const p = await newPage({ width: w, height: h, theme });
await open(p);
await p.keyboard.type("90");
await p.click('[data-aw="shared"]');
const box = await p.$eval("#add-form", (n) => { const r = n.getBoundingClientRect(); return { l: r.left, r: r.right, b: r.bottom, sw: document.documentElement.scrollWidth }; });
check(box.l >= 0 && box.r <= w && box.sw <= w, w + "px " + theme + ": sheet fits", box);
const goBox = await p.$eval("#add-go", (n) => n.getBoundingClientRect().bottom);
check(goBox <= h, w + "px: main button visible", goBox);
await shot(p, "desk-" + w + "-" + theme);
await p.context().close();
}
section("Phone: one page, the keypad while the amount is typed, Repeat");
{
const p = await newPage({ width: 390, height: 844, theme: "dark", touch: true });
await p.click(".w-tabadd");
await p.waitForSelector("#add-form.w-addp");
await p.waitForTimeout(350);
const sheet = await p.$eval("#add-form", (n) => n.getBoundingClientRect());
check(sheet.top <= 0.5 && sheet.height >= 843 && sheet.width === 390, "phone: the whole screen", sheet);
check(await p.$$eval(".w-grab", (l) => l.length) === 0, "no grabber: the sheet can’t be dragged");
check(await visible(p, "#add-keypad") && await visible(p, "#add-name") && await visible(p, "#add-catrow") && !(await visible(p, "#add-go")),
"one page: the keypad in the main button’s place, with the name and categories above it");
const pad = await p.$eval("#add-keypad", (n) => n.getBoundingClientRect());
check(pad.bottom >= 843 && pad.top > 844 / 2, "the keypad sits at the bottom, in thumb reach", pad);
check(await p.getAttribute("#add-amt", "inputmode") === "none", "no phone keyboard over the keypad");
for (const k of ["1", "2", "+", "3"]) await p.tap('[data-k="' + k + '"]');
check(await p.inputValue("#add-amt") === "12+3" && /15\.00/.test(await text(p, "#add-calc")), "keypad sums", await p.inputValue("#add-amt"));
check(await focused(p) === "add-amt", "keys leave the focus in the amount");
await p.tap('[data-k="back"]');
await p.tap('[data-k="."]');
await p.tap('[data-k="5"]');
check(await p.inputValue("#add-amt") === "12+0.5", "decimal and back", await p.inputValue("#add-amt"));
check(await p.$eval("#add-amt", (i) => i.scrollWidth <= i.clientWidth), "phone: the whole amount shows");
const keyH = await p.$eval('[data-k="5"]', (n) => n.getBoundingClientRect().height);
check(keyH >= 44, "keys at least 44 px", keyH);
check(await p.$$eval("#add-form .w-chip", (l) => l.every((c) => c.closest("#add-tags"))), "no chips but tags");
check(await text(p, "#add-amt").then(() => p.getAttribute("#add-amt", "placeholder")) === "0.00", "phone: 0.00 like the desktop");
const catH = await p.$eval("#add-catrow", (n) => [n.getBoundingClientRect().height, parseFloat(getComputedStyle(n).fontSize)]);
check(catH[0] >= 44 && catH[1] >= 16, "phone: rows are touch size, with text iOS won't zoom", catH);
await shot(p, "phone-1");
check(await text(p, "#add-knext") === "Next", "the keypad’s last key: Next, with no category yet");
await p.tap("#add-knext");
check(await visible(p, '[data-pstep="cat"]') && !(await visible(p, "#add-keypad")) && await focused(p) !== "add-name", "Next: on to the category, without bringing up the phone’s keyboard");
await p.tap('[data-pstep="cat"] [data-step="main"]');
check(await p.inputValue("#add-amt") === "12.50", "the sum is worked out", await p.inputValue("#add-amt"));
await p.tap("#add-name");
const likely = await p.$$eval("#add-sugg [data-si]", (l) => l.map((b) => b.querySelector("small").textContent));
check(likely.length >= 1 && likely.length <= 3 && likely.every((x) => / times|Every /.test(x)), "the name suggests the likely payees, with their category and usual amount", likely);
check(await p.$("#add-sugg [aria-selected=true]") === null && await p.getAttribute("#add-name", "aria-activedescendant") === null, "none picked yet");
await p.press("#add-name", "Enter");
check(await p.inputValue("#add-name") === "" && !(await visible(p, "#add-nerr")), "Done on an empty name does not pick one, and is no error");
check(await visible(p, "#add-go") && await text(p, "#add-go") === "Pick a category", "the main button is back, naming what is missing", await text(p, "#add-go"));
await shot(p, "phone-name");
await p.tap("#add-amt");
check(await visible(p, "#add-keypad") && !(await visible(p, "#add-go")), "a tap on the amount: the keypad again");
await p.tap("#lbl-who");
check(!(await visible(p, "#add-keypad")) && await visible(p, "#add-go"), "a tap anywhere else closes it");
await p.tap("#add-amt");
await p.keyboard.press("Escape");
check(!(await visible(p, "#add-keypad")) && await isOpen(p), "Escape closes the keypad, not the sheet");
await p.tap("#add-name");
await p.fill("#add-name", "Newsstand");
check(!(await visible(p, "#add-picks")), "typing a name hides the picks");
await p.press("#add-name", "Enter");
check(await focused(p) !== "add-name" && !(await visible(p, "#add-keypad")), "Enter: the keyboard goes, to show the page");
check(/Pick a category/.test(await text(p, "#add-go")), "main button names what is missing");
await pickCat(p, "Food");
await p.click('#add-who [data-aw="shared"]');
check(/^Half each: .*6\.25/.test(await text(p, "#add-splitnote")) && await text(p, "#add-smv") === "Evenly", "Split row says Evenly, each part under it", await text(p, "#add-splitnote"));
await p.click("#add-smrow");
check(await visible(p, '[data-pstep="split"]') && /Each pays 6\.25/.test(await text(p, "#add-each")) && /owes .* 6\.25\s.*for this\./.test(await text(p, "#add-owes")), "the Split page: each part and who owes whom", [await text(p, "#add-each"), await text(p, "#add-owes")]);
await back(p);
const go = await p.$eval("#add-go", (n) => n.getBoundingClientRect());
check(go.bottom <= 844 && go.top > 600 && go.height >= 44, "main button in thumb reach", go);
check(await text(p, "#add-go") === "Add Expense", "main button: Add Expense");
await shot(p, "phone-2");
await p.click("#add-reprow");
check(await visible(p, '[data-pstep="repeat"]') && !(await visible(p, '[data-pstep="main"]')), "Repeat opens over the page");
await p.click('[data-pfq="biweekly"]');
check(await p.getAttribute('[data-pfq="biweekly"]', "aria-checked") === "true" && await visible(p, '[data-pfq="biweekly"] .w-ack'), "checkmark on the choice");
await p.selectOption("#add-endsel", "count");
await p.fill("#add-count", "12");
check(/^Next: /.test(await text(p, "#add-next")), "the next dates", await text(p, "#add-next"));
await shot(p, "phone-3");
await back(p);
check(await focused(p) === "add-reprow" && /Every 2 weeks, 12 times/.test(await text(p, "#add-repv")), "back on the page, at Repeat, showing the choice", await text(p, "#add-repv"));
check(await p.$eval("#add-amt", (i) => i.scrollWidth <= i.clientWidth && i.value === "12.50"), "the amount still shows whole");
await p.click("#add-reprow");
await p.click('[data-pfq="never"]');
await back(p);
check(await text(p, "#add-repv") === "Never", "Never turns it off");
const resp = p.waitForResponse((x) => x.url().endsWith("/api/transactions") && x.request().method() === "POST");
await p.click("#add-go");
const tx = await (await resp).json();
check(tx.amount === "12.50" && tx.category === "Food" && tx.person_id === null, "saved from the phone", tx);
await toastText(p, /Added/);
// a pick: the payee and its category, and the amount stays as typed
await p.click(".w-tabadd");
await p.waitForSelector("#add-form.kp");
await p.tap('[data-k="7"]');
await p.tap("#add-knext");
await p.tap('[data-pstep="cat"] [data-step="main"]');
await p.tap("#add-name");
const pickName = await p.$eval('[data-si="0"] b', (n) => n.textContent);
await p.tap('[data-si="0"]');
check(await p.inputValue("#add-name") === pickName && (await catValue(p)) !== "Pick" && !(await visible(p, "#add-keypad")) && await focused(p) !== "add-name" && await text(p, "#add-go") === "Add Expense" && /^7([.,]00)?$/.test(await p.inputValue("#add-amt")),
"a pick fills the payee and category, keeps the amount, and it is ready to add", pickName);
await p.click('#add-form section:not([hidden]) [data-close]');
if (await visible(p, "#dlg .w-dialog")) await p.click("#dlg [data-dlg-ok]");
// edit on the phone: everything shows, no keypad until the amount is tapped
const one = await core(p, (c) => c.state.tx.find((x) => x.type === "expense" && !x.gr));
await open(p, { txId: one.id });
check(!(await visible(p, "#add-keypad")) && await visible(p, "#add-name") && await visible(p, "#add-del") && await focused(p) === "add-form" && await text(p, "#add-go") === "Save", "phone edit: Save at the top, Delete as the last row, no keypad");
await shot(p, "phone-edit");
await p.tap("#add-amt");
check(await visible(p, "#add-keypad") && await text(p, "#add-knext") === "Done", "editing the amount: the keypad, with Done");
await p.click('#add-form section:not([hidden]) [data-close]');
if (await visible(p, "#dlg .w-dialog")) await p.click("#dlg [data-dlg-ok]");
// Split across categories, from the phone's Category page
await p.waitForTimeout(400);
await open(p, {});
await p.tap('[data-k="9"]');
await p.tap("#add-knext");
await p.click("[data-psplit]");
check(await visible(p, "#add-parts") && !(await visible(p, "#add-catrow")), "phone: Split across categories shows the parts");
await p.click('#add-form section:not([hidden]) [data-close]');
if (await visible(p, "#dlg .w-dialog")) await p.click("#dlg [data-dlg-ok]");
await p.context().close();
}
section("Arabic, right to left");
{
await api(page, "PUT", "/language/ar");
const p = await newPage({ width: 1440, height: 900 });
await open(p);
check(await p.evaluate(() => document.documentElement.dir) === "rtl", "page is right to left");
await p.keyboard.type("12+3");
const calc = await p.$eval("#add-calc", (n) => n.getBoundingClientRect().left), amt = await p.$eval("#add-amt", (n) => n.getBoundingClientRect().left);
check(calc < amt, "total sits at the start side (left) in RTL", { calc, amt });
await shot(p, "desk-ar");
await p.context().close();
const p2 = await newPage({ width: 390, height: 844 });
await open(p2);
await shot(p2, "phone-ar");
// the date cell opens the calendar under it; right to left, the left arrow goes to the next day
await p2.click('[data-k="5"]');
await p2.fill("#add-name", "Date check");
await p2.press("#add-name", "Enter");
await p2.click("#add-datecell");
check(await visible(p2, "#add-cal") && await p2.getAttribute("#add-datecell", "aria-expanded") === "true", "phone: the date cell opens the calendar");
await p2.keyboard.press("ArrowRight");
await p2.keyboard.press("ArrowLeft");
await p2.keyboard.press("ArrowLeft");
check(await p2.evaluate(() => document.activeElement.dataset.cd) === TODAY, "right to left: arrows follow the reading direction, and stop at today");
await p2.keyboard.press("ArrowRight");
await p2.keyboard.press("Enter");
check(!(await visible(p2, "#add-cal")) && await focused(p2) === "add-datecell" && await p2.getAttribute("#add-datecell", "aria-expanded") === "false", "picking a day closes it, back on the cell");
await p2.context().close();
await api(page, "PUT", "/language/en");
}
section("Three people: shares for N");
await api(page, "POST", "/people", { name: "Sam" });
{
const p = await newPage();
await open(p);
await p.keyboard.type("10");
await p.fill("#add-name", "Dinner for three");
await pickCat(p, "Food");
check(await p.$$eval("#add-who [data-aw]", (l) => l.length) === 4, "three people and Shared");
await p.click('[data-aw="shared"]');
check(/^Equal parts: /.test(await text(p, "#add-splitnote")), "for three: equal parts", await text(p, "#add-splitnote"));
await p.selectOption("#add-bysel", "1");
let resp = p.waitForResponse((x) => x.url().endsWith("/api/transactions") && x.request().method() === "POST");
await p.click("#add-go");
let tx = await (await resp).json();
check(JSON.stringify(tx.shares) === JSON.stringify({ 1: 334, 2: 333, 3: 333 }), "10.00 three ways: 3.34 / 3.33 / 3.33, spare to the payer", tx.shares);
await toastText(p, /Added/);
await open(p);
await p.keyboard.type("10");
await p.fill("#add-name", "Dinner for three");
await pickCat(p, "Food");
await p.click('[data-aw="shared"]');
await p.selectOption("#add-smsel", "custom");
check(await p.$$eval("[data-cust]", (l) => l.length) === 3, "Custom: one field per person");
await p.fill('[data-cust="1"]', "5");
await p.fill('[data-cust="2"]', "3");
await p.fill('[data-cust="3"]', "1");
check(/1\.00.*left to share/.test(await text(p, "#add-splitnote")) && /Share 1\.00.*more/.test(await text(p, "#add-go")), "remaining shown, button names it", [await text(p, "#add-splitnote"), await text(p, "#add-go")]);
await p.fill('[data-cust="3"]', "2");
resp = p.waitForResponse((x) => x.url().endsWith("/api/transactions") && x.request().method() === "POST");
await p.click("#add-go");
tx = await (await resp).json();
check(JSON.stringify(tx.shares) === JSON.stringify({ 1: 500, 2: 300, 3: 200 }), "custom shares saved exactly", tx.shares);
await toastText(p, /Added/);
await p.context().close();
}
section("One person: no Who, no Shared");
{
const people = (await api(page, "GET", "/people")).data;
for (const x of people.filter((x) => x.id !== 1)) await api(page, "DELETE", "/people/" + x.id, { move_to: 1 });
const p = await newPage();
await open(p);
check(!(await visible(p, "#add-whorow")) && !(await visible(p, '[data-aw="shared"]')), "no Who row and no Shared");
await p.keyboard.type("4");
await p.fill("#add-name", "Stamps");
await pickCat(p, "Food");
const resp = p.waitForResponse((x) => x.url().endsWith("/api/transactions") && x.request().method() === "POST");
await p.click("#add-go");
const tx = await (await resp).json();
check(tx.person_id === 1 && tx.shares === null, "saved as the one person's", tx.person_id);
await toastText(p, /Added/);
await p.click(".w-hadd").catch(() => {});
await p.context().close();
}
console.log("\n" + passed + " passed, " + failed + " failed");
if (errors.length) { console.log("Console errors:\n " + errors.join("\n ")); }
await browser.close();
process.exit(failed || errors.length ? 1 : 0);