tests/e2e/settings.mjs
Rama main · réplica de lectura del repositorio original.
/* Settings, every pane and dialog, against a real server.
BASE=http://localhost:8805 EMPTY=http://localhost:8807 SHOTS=/tmp/shots node settings.mjs
BASE is a fresh demo household (DEMO=true, people Alex and Ana, sign-in off). The run changes it,
so start it on an empty data folder each time. EMPTY (optional) is a fresh server without DEMO, for
the one-person household and empty states. SHOTS (optional) is a folder for screenshots.
Fails on any console error or page error. */
import { chromium } from "playwright";
import { createHmac } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const BASE = process.env.BASE || "http://localhost:8805";
const EMPTY = process.env.EMPTY || "";
const SHOTS = process.env.SHOTS || "";
if (SHOTS && !existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true });
const TMP = mkdtempSync(join(tmpdir(), "wally-settings-"));
let failures = 0;
const ok = (cond, msg) => { if (cond) console.log(" ok " + msg); else { failures++; console.log(" FAIL " + msg); } };
const section = (s) => console.log("\n" + s);
/* ---------- two-step codes, worked out here as an authenticator app would ---------- */
function base32(s) {
const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
for (const ch of s.replace(/[\s=]/g, "").toUpperCase()) bits += abc.indexOf(ch).toString(2).padStart(5, "0");
const out = [];
for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(out);
}
function totp(secret, at = Date.now()) {
const counter = Math.floor(at / 1000 / 30);
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const h = createHmac("sha1", base32(secret)).update(buf).digest();
const o = h[h.length - 1] & 15;
return String(((h.readUInt32BE(o) & 0x7fffffff) % 1e6)).padStart(6, "0");
}
/* ---------- the browser ---------- */
const browser = await chromium.launch();
let errors = [];
let allowHttp = 0; // expected 4xx answers (wrong password and so on) log a resource error in the console
async function newPage(base, opts = {}) {
const ctx = await browser.newContext({ viewport: opts.viewport || { width: 1440, height: 900 }, colorScheme: opts.dark ? "dark" : "light", serviceWorkers: "block", acceptDownloads: true });
await ctx.addInitScript((who) => { if (!localStorage.getItem("wally.person")) localStorage.setItem("wally.person", who); }, opts.who || "1");
const page = await ctx.newPage();
page.on("console", (m) => {
if (m.type() !== "error") return;
if (allowHttp && /Failed to load resource: the server responded with a status of 4\d\d/.test(m.text())) return;
errors.push(m.text());
});
page.on("pageerror", (e) => errors.push("PAGEERROR " + e.message));
page.on("dialog", (d) => d.dismiss());
return { ctx, page, base };
}
async function expectHttpError(fn) { allowHttp++; try { return await fn(); } finally { await new Promise((r) => setTimeout(r, 150)); allowHttp--; } }
/* The API, as this browser (same cookies), saying who it is while sign-in is off */
function apiOf(env) {
return async (method, path, body, headers = {}) => {
const r = await env.ctx.request.fetch(env.base + "/api" + path, { method, data: body, headers: { "X-Wally-Person": "1", ...headers }, failOnStatusCode: false });
const text = await r.text();
let data = null; try { data = JSON.parse(text); } catch (e) { data = text; }
return { status: r.status(), data };
};
}
const W = await newPage(BASE);
const { page } = W;
const api = apiOf(W);
async function open(path) {
await page.goto(BASE + path, { waitUntil: "networkidle" });
await page.waitForSelector("#set-pane .w-gl, #set-pane .w-group, .w-setlist", { timeout: 10000 });
}
async function pane(id) {
await page.click(`#set-nav a[data-pane="${id}"]`);
await page.waitForFunction((id) => location.pathname === "/settings/" + id && document.querySelector("#set-pane .w-gl, #set-pane .w-group"), id);
await page.waitForTimeout(150);
}
const dialog = () => page.locator("#dlg .w-dialog");
async function dlgOk() { await page.click("#dlg [data-dlg-ok]"); }
async function dlgCancel() { await page.click("#dlg [data-dlg-x]"); await dlgClosed(); }
async function dlgClosed() { await page.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 8000 }); }
async function dlgError() {
await page.waitForFunction(() => { const e = document.getElementById("dlg-err"); return e && !e.hidden && e.textContent; }, null, { timeout: 8000 });
return page.textContent("#dlg-err");
}
async function toastText(re) {
await page.waitForFunction((src) => document.getElementById("toast").classList.contains("on") && new RegExp(src).test(document.getElementById("toast-t").textContent), re.source, { timeout: 8000 });
return page.textContent("#toast-t");
}
async function undo() { await page.click("#toast-undo"); await toastText(/Undone/); }
async function paneText() { return page.textContent("#set-pane"); }
/* An item's own page (a person, a category, a tag), opened from its row */
async function openRow(sel) { await page.click(sel); await page.waitForSelector("#set-pane .w-shero"); await page.waitForTimeout(100); }
async function back() { await page.click("#set-head .w-sback"); await page.waitForFunction(() => !document.querySelector("#set-pane .w-shero")); await page.waitForTimeout(100); }
async function editMode(on) { await page.click(`#set-act [data-act="${on ? "edit" : "done"}"]`); await page.waitForTimeout(150); }
async function shot(name, p = page) { if (SHOTS) await p.screenshot({ path: join(SHOTS, name + ".png"), fullPage: true }); }
/* ======================================================================= */
section("Layout and moving between panes");
await open("/settings");
ok(await page.locator("#set-nav a").count() === 8, "eight panes in five groups");
ok((await page.locator("#set-nav .grp > span").allTextContents()).join() === "Household,General,Security,Data,Advanced", "Household, General, Security, Data, Advanced");
ok(await page.getAttribute('#set-nav a[data-pane="people"]', "aria-current") === "page", "/settings opens People on desktop");
ok((await page.textContent("#set-title")) === "People" && await page.locator("#set-pane .w-gl").count() === 1, "People pane is drawn, with its title");
ok(/Wally \d/.test(await page.textContent("#set-nav")), "version at the bottom of the panes");
ok(/2/.test(await page.textContent('#set-nav a[data-pane="people"] .v')), "People shows how many");
for (const id of ["categories", "tags", "region", "appearance", "import", "keys", "security", "people"]) await pane(id);
ok(true, "every pane opens");
await page.goBack(); await page.waitForFunction(() => location.pathname === "/settings/security");
ok(true, "Back goes to the pane before");
for (const [old, now] of [["signin", "security"], ["privacy", "security"], ["language", "region"], ["currency", "region"]]) {
await open("/settings/" + old);
ok(await page.getAttribute(`#set-nav a[data-pane="${now}"]`, "aria-current") === "page", "/settings/" + old + " opens " + now);
}
await open("/settings/nosuchpane");
ok(await page.getAttribute('#set-nav a[data-pane="people"]', "aria-current") === "page", "an unknown pane falls back to People");
// Keyboard: the panes are links in the tab order
await page.focus('#set-nav a[data-pane="tags"]');
await page.keyboard.press("Enter");
await page.waitForFunction(() => location.pathname === "/settings/tags");
ok(true, "Enter on a pane link opens it");
/* ======================================================================= */
section("People");
await open("/settings/people");
ok(await page.locator('#set-pane a.w-gr[data-pane^="people/"]').count() === 2 && !/Rename|Switch/.test(await paneText()), "one row per person, opening their page; no links on the rows");
await page.click('[data-act="add"]');
ok(await page.locator("#dlg .w-sheetd").isVisible(), "Add a Person is a sheet");
ok(await page.isDisabled("#dlg [data-dlg-ok]"), "Add waits for a name");
await page.fill("#np-name", "Sam");
await dlgOk(); await dlgClosed();
await toastText(/Added Sam/);
let people = (await api("GET", "/people")).data;
ok(people.some((p) => p.name === "Sam"), "Sam is saved");
const samId = people.find((p) => p.name === "Sam").id;
await page.click('[data-act="add"]');
await page.fill("#np-name", "sam");
ok(await page.isDisabled("#dlg [data-dlg-ok]") && /Sam is already in the household/.test(await page.textContent("#np-name-note")), "a name already taken: Add waits, and the footnote says who has it");
await dlgCancel();
// Her page: rename from the Name row
await openRow(`#set-pane a[data-pane="people/${samId}"]`);
ok(page.url().endsWith("/settings/people/" + samId) && /Sam/.test(await page.textContent("#set-pane .w-shero")), "her page: picture, name, how many transactions");
ok(await page.locator('#set-pane [data-act="switch"]').count() === 1 && await page.locator('#set-pane .w-gr.red[data-act="remove"]').count() === 1, "Switch to Sam, and one red Remove row");
await page.click('#set-pane [data-act="rename"]');
await page.fill("#rp-name", "Samantha");
await page.keyboard.press("Enter"); await dlgClosed();
await toastText(/Renamed to Samantha/);
ok(/Samantha/.test(await page.textContent("#set-pane .w-shero")), "the page shows the new name");
await back();
// Edit: reorder with the handles (arrow keys)
await editMode(true);
ok(await page.locator("#set-pane [data-grip]").count() === 3 && await page.locator('#set-pane a.w-gr').count() === 0, "Edit shows a handle on each row");
await page.focus(`#set-pane [data-grip="${samId}"]`);
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(400);
people = (await api("GET", "/people")).data;
ok(people.map((p) => p.name).join() === "Alex,Samantha,Ana", "moving up saves the order");
await editMode(false);
await page.reload(); await page.waitForSelector("#set-pane .w-gl");
ok((await page.locator("#set-pane .w-gr .k").allTextContents()).slice(0, 3).join("|").includes("Samantha"), "order kept after reload");
// Remove, moving her transactions to Ana (three people: the alert asks who)
const own = await api("POST", "/transactions", { name: "Sam's lunch", category: "Food", amount: "9.50", type: "expense", date: "2026-10-20", person_id: samId });
ok(own.status === 200, "a transaction of Samantha's for the test");
await openRow(`#set-pane a[data-pane="people/${samId}"]`);
await page.click('#set-pane [data-act="remove"]');
await page.waitForSelector("#dlg .w-alert #rp-to");
ok(/Remove Samantha\?/.test(await dialog().textContent()), "remove is an alert that names her");
ok(await page.inputValue("#rp-to") === "1", "it suggests you as the heir");
await page.selectOption("#rp-to", "2");
await dlgOk(); await dlgClosed();
await toastText(/Removed Samantha/);
await page.waitForFunction(() => location.pathname === "/settings/people");
ok(true, "and goes back to the list");
const moved = (await api("GET", "/transactions/search?text=" + encodeURIComponent("Sam's lunch"))).data;
ok(moved.length === 1 && moved[0].person_id === 2, "her transaction went to Ana");
ok(!(await paneText()).includes("Samantha"), "she is gone from the list");
// Two people: the alert says where things go in a sentence
await openRow('#set-pane a[data-pane="people/2"]');
await page.click('#set-pane [data-act="remove"]');
await page.waitForSelector("#dlg .w-alert");
ok(/move to Alex/.test(await dialog().textContent()) && await page.locator("#rp-to").count() === 0, "with two people, no choice: it says they move to Alex");
await dlgCancel();
await back();
// No settling up; switching lives on the other person's page and under your name
ok(!/Settled payments|Settle up|owes/.test(await paneText()), "no settle up and no who-owes-whom");
ok(!/Turn a tag into a person/.test(await paneText()), "turning a tag into a person moved to Tags");
ok(await page.locator("#side .w-me .w-meb").count() === 1 && await page.locator("#side .w-me [data-switch]").count() === 1, "Switch person at the foot of the sidebar, under your name");
/* ======================================================================= */
section("Categories");
await pane("categories");
ok((await page.locator("#set-pane .w-glh").allTextContents()).join() === "Expenses,Income", "Expenses, then Income");
ok(await page.locator("#set-pane .w-swb, #set-pane .w-link").count() === 0, "no color dots, Rename links or bins on the rows");
{
// Biggest spender first, colored through the rainbow in that order
const tiles = await page.$$eval("#set-pane .w-gl", (gs) => [...gs[0].querySelectorAll(".w-ico")].slice(0, 3).map((e) => e.getAttribute("style")));
ok(tiles.length === 3 && ["--k1", "--k2", "--k3"].every((k, i) => tiles[i].includes("var(" + k + ")")), "the first three rows are red, orange, yellow");
}
await openRow('#set-pane a[data-pane="categories/Rent"]');
ok(/Rent/.test(await page.textContent("#set-pane .w-shero")) && /a month/.test(await page.textContent("#set-pane .w-shero")), "Rent's page: icon, name, how much a month");
ok(await page.locator('#set-pane [data-act="color"]').count() === 0, "no color to pick: it follows spending");
await page.click('#set-pane [data-act="icon"]');
ok(await page.locator("#ic-list > .w-icgrp").first().locator("[role=radio]").count() === 64 && (await page.getAttribute('#ic-list [data-i="rent"]', "aria-checked")) === "true", "64 suggested icons, the one in use ringed");
// All of Lucide's are listed, in blocks drawn only as they come near the screen
await page.waitForFunction(() => !document.getElementById("ic-wait") && [...document.querySelectorAll("#ic-list .w-icons")].reduce((n, b) => n + +b.dataset.n, 0) > 1500, null, { timeout: 8000 });
ok(await page.locator("#ic-list [data-i]").count() < 400, "only the icons near the screen are drawn");
ok(/Search [\d,]+ icons/.test(await page.getAttribute("#ic-q", "placeholder")), "then all of Lucide's, and the search says how many");
await page.fill("#ic-q", "piggy");
ok((await page.locator("#ic-list [data-i]").first().getAttribute("data-i")) === "savings", "searching by Lucide's words finds Wally's own icon first");
await page.fill("#ic-q", "zzzz");
ok(/No icons match/.test(await page.textContent("#ic-list")), "nothing found says so");
await page.fill("#ic-q", "");
await page.click('#dlg .w-icons [data-i="sofa"]');
ok((await page.getAttribute("#dlg .w-icprev use", "href")) === "#i-sofa", "the big icon on top shows the pick");
await dlgOk(); await dlgClosed();
ok((await api("GET", "/categories/icons")).data.Rent === "sofa" && (await page.getAttribute("#set-pane .w-shero use", "href")) === "#i-sofa", "Done keeps the icon picked, and Rent's page shows it");
await page.click('#set-pane [data-act="icon"]');
await page.fill("#ic-q", "hamburger");
await page.click('#ic-list [data-i="l-hamburger"]'); await dlgOk(); await dlgClosed();
ok((await api("GET", "/categories/icons")).data.Rent === "l-hamburger" && (await page.getAttribute("#set-pane .w-shero use", "href")) === "/icons/lucide.svg#hamburger", "one of Lucide's is kept too, drawn from the library file");
await page.click('#set-pane [data-act="icon"]');
await page.click('#dlg .w-icons [data-i="rent"]'); await dlgOk(); await dlgClosed();
ok(!("Rent" in (await api("GET", "/categories/icons")).data), "picking the name's own icon goes back to following the name");
await back();
await page.click('#set-pane [data-act="add"]');
await page.fill("#cat-new", "Pets");
await page.keyboard.press("Enter"); await dlgClosed();
await toastText(/Added Pets/);
ok((await api("GET", "/categories")).data.includes("Pets"), "a category is added");
await openRow('#set-pane a[data-pane="categories/Pets"]');
await page.click('#set-pane [data-act="rename"]');
await page.fill("#cr-name", "food");
ok(await page.isDisabled("#dlg [data-dlg-ok]") && /Food is already a category\. Merge/.test(await page.textContent("#cr-name-note")), "renaming into an existing name is refused, and the footnote points to Merge");
await page.fill("#cr-name", "");
ok(await page.isDisabled("#dlg [data-dlg-ok]") && /Type a name/.test(await page.textContent("#cr-name-note")), "an emptied name: Done waits and says why");
await page.fill("#cr-name", "Animals");
await dlgOk(); await dlgClosed();
await page.waitForFunction(() => location.pathname === "/settings/categories/Animals");
ok((await api("GET", "/categories")).data.includes("Animals"), "renamed, and its page follows the new name");
await page.click('#set-pane [data-act="delete"]');
await page.waitForSelector("#dlg .w-alert");
ok(/No transactions use it/.test(await dialog().textContent()), "an unused category: a short alert");
await dlgOk(); await dlgClosed();
await toastText(/Deleted Animals/);
ok(!(await api("GET", "/categories")).data.includes("Animals"), "deleted");
await undo();
ok((await api("GET", "/categories")).data.includes("Animals"), "Undo brings it back");
await page.goto(BASE + "/settings/categories", { waitUntil: "networkidle" }); await page.waitForSelector("#set-pane .w-gl");
// Edit: a red minus on each row deletes; Travel's transactions move where you say
const stats = Object.fromEntries((await api("GET", "/categories/stats")).data.map((c) => [c.name, c.count]));
await editMode(true);
ok(await page.locator("#set-pane .w-gminus").count() >= 9, "Edit puts a delete button on each row");
await page.click('#set-pane .w-gminus[data-name="Travel"]');
await page.waitForSelector("#cd-to");
ok(new RegExp(stats.Travel + " transactions").test(await dialog().textContent()), "delete asks where its " + stats.Travel + " transactions go");
await page.selectOption("#cd-to", "Shopping");
await dlgOk(); await dlgClosed();
await toastText(/Deleted Travel/);
ok(await page.locator('#set-pane [data-k="add"]').count() === 1 && await page.locator("#set-pane .w-gminus").count() >= 8, "still in Edit after a delete, with Add a category at the foot");
const stats2 = Object.fromEntries((await api("GET", "/categories/stats")).data.map((c) => [c.name, c.count]));
ok(!("Travel" in stats2) && stats2.Shopping === stats.Shopping + stats.Travel, "they moved to Shopping");
await editMode(false);
// Merge from a category's page
await openRow('#set-pane a[data-pane="categories/Animals"]');
await page.click('#set-pane [data-act="merge"]');
await page.click('#dlg [data-to="Miscellaneous"]');
await dlgOk(); await dlgClosed();
await toastText(/Merged Animals into Miscellaneous/);
ok(!(await api("GET", "/categories")).data.includes("Animals"), "merging takes the category away");
await page.reload(); await page.waitForSelector("#set-pane .w-gl");
ok(!(await paneText()).includes("Travel"), "and Travel stays gone after reload");
/* ======================================================================= */
section("Tags");
await pane("tags");
await page.click('#set-pane [data-act="add"]');
await page.fill("#tag-new", "#work");
await dlgOk(); await dlgClosed();
await toastText(/Added #work/);
await openRow('#set-pane a[data-pane="tags/work"]');
await page.click('#set-pane [data-act="rename"]');
await page.fill("#tr-name", "job");
await dlgOk(); await dlgClosed();
await page.waitForFunction(() => location.pathname === "/settings/tags/job");
ok((await api("GET", "/tags")).data.includes("job"), "a tag is renamed");
await page.click('#set-pane [data-act="icon"]');
ok((await page.getAttribute('#ic-list [data-i="tag"]', "aria-checked")) === "true" && await page.locator("#ic-list > .w-icgrp").first().locator("[role=radio]").count() === 64, "a tag's picker starts on the plain tag");
await page.click('#dlg .w-icons [data-i="palm"]');
await dlgOk(); await dlgClosed();
ok((await api("GET", "/tags/icons")).data.job === "palm" && (await page.getAttribute("#set-pane .w-shero use", "href")) === "#i-palm", "Done keeps a tag's icon, and its page shows it");
await page.click('#set-pane [data-act="icon"]');
await page.click('#dlg .w-icons [data-i="tag"]');
await dlgOk(); await dlgClosed();
ok(!("job" in (await api("GET", "/tags/icons")).data) && (await page.getAttribute("#set-pane .w-shero use", "href")) === "#i-tag", "picking the plain tag goes back to it");
// Typing an existing name in Rename is refused, as for categories: Merge is the way
await page.click('#set-pane [data-act="rename"]');
await page.fill("#tr-name", "coffee");
ok(/#coffee is already a tag\. Merge/.test(await page.textContent("#tr-name-note")) && await page.isDisabled("#dlg [data-dlg-ok]") && (await page.textContent("#dlg [data-dlg-ok]")) === "Done", "rename into an existing tag waits, and points to Merge");
await dlgCancel();
await page.click('#set-pane [data-act="delete"]');
await dlgOk(); await dlgClosed();
await page.waitForFunction(() => location.pathname === "/settings/tags");
ok(!(await api("GET", "/tags")).data.includes("job"), "a tag is deleted after confirming");
// Deleting in Edit keeps Edit, and Add a tag stays at the foot
await page.click('#set-pane [data-act="add"]');
await page.fill("#tag-new", "#temp");
await dlgOk(); await dlgClosed();
await toastText(/Added #temp/);
await editMode(true);
await page.click('#set-pane .w-gminus[data-name="temp"]');
await dlgOk(); await dlgClosed();
await toastText(/Deleted #temp/);
ok(await page.locator('#set-pane [data-k="add"]').count() === 1 && await page.locator("#set-pane .w-gminus").count() > 0, "a tag deleted in Edit: still in Edit, with Add a tag at the foot");
await editMode(false);
const tstats = Object.fromEntries((await api("GET", "/tags/stats")).data.map((c) => [c.name, c.count]));
await openRow('#set-pane a[data-pane="tags/holiday"]');
await page.click('#set-pane [data-act="merge"]');
await page.click('#dlg [data-to="gift"]');
ok(new RegExp("Its " + tstats.holiday + " transactions get #gift instead").test(await page.textContent("#tm-what")), "Merge's header says how many transactions get the tag picked");
await dlgOk(); await dlgClosed();
await toastText(/Merged #holiday into #gift/);
const tstats2 = Object.fromEntries((await api("GET", "/tags/stats")).data.map((c) => [c.name, c.count]));
ok(!("holiday" in tstats2) && tstats2.gift === tstats.gift + tstats.holiday, "merge moves every transaction to the other tag");
// Make this a person
const before = (await api("GET", "/people/from-tag/preview?tag=sport")).data.count;
await openRow('#set-pane a[data-pane="tags/sport"]');
await page.click('#set-pane [data-act="fromtag"]');
await page.click('#dlg [data-who="2"]');
await page.waitForFunction((n) => document.getElementById("ft-what").textContent.includes("Its " + n + " transactions become Ana’s own"), String(before));
ok((await page.textContent("#dlg [data-dlg-ok]")) === "Make", "Make #sport a Person says how many become whose (" + before + "), and its action is Make");
await dlgOk(); await dlgClosed();
await toastText(new RegExp("Moved " + before + " transactions to Ana"));
const tagsAfter = (await api("GET", "/tags/stats")).data;
ok(!tagsAfter.some((t) => t.name === "sport" && t.count > 0), "the tag is taken off them");
/* ======================================================================= */
section("Language & Region");
await pane("region");
ok(/Language/.test(await paneText()) && /Currency/.test(await paneText()) && /Week starts on/.test(await paneText()) && /Time zone/.test(await paneText()), "language, currency, amounts, week and time zone in one place");
await page.click('#set-pane a[data-pane="region/currency"]');
await page.waitForSelector("#cur-q");
ok((await page.textContent("#set-title")) === "Currency" && /Language & Region/.test(await page.textContent("#set-head .w-sback")), "Currency opens as a list, with a way back");
await page.fill("#cur-q", "dollar");
await page.waitForTimeout(150);
ok(await page.locator('#set-pane [data-act="pick-cur"][data-name="USD"]').count() === 1 && await page.locator('#set-pane [data-act="pick-cur"][data-name="GBP"]').count() === 0, "search narrows the list");
await page.click('#set-pane [data-act="pick-cur"][data-name="USD"]');
await toastText(/USD/);
ok((await api("GET", "/currency")).data.selected === "USD", "picking one makes it the main currency");
await page.fill("#cur-q", "");
await page.click('#set-pane [data-act="add"]');
await page.fill("#cu-name", "xbt"); await page.fill("#cu-sym", "₿");
await dlgOk(); await dlgClosed();
ok((await api("GET", "/currency")).data.currencies.some((c) => c.name === "XBT" && c.symbol === "₿"), "a currency is added (code upper-cased)");
await editMode(true);
ok(await page.locator('#set-pane .w-gminus[data-name="USD"]').count() === 0, "the currency in use can't be deleted");
await page.click('#set-pane [data-act="edit-cur"][data-name="XBT"]');
await page.waitForSelector("#cu-name");
ok(await page.evaluate(() => document.activeElement && document.activeElement.matches("[data-dlg-ok]")), "editing a currency opens with Save ready, no field focused");
await page.fill("#cu-name", "XBC");
await dlgOk(); await dlgClosed();
ok((await api("GET", "/currency")).data.currencies.some((c) => c.name === "XBC"), "and edited, in Edit");
await page.click('#set-pane .w-gminus[data-name="XBC"]');
await toastText(/Deleted XBC/);
await undo();
ok((await api("GET", "/currency")).data.currencies.some((c) => c.name === "XBC"), "delete has Undo");
await editMode(false);
await page.click("#set-head .w-sback");
await page.waitForSelector("#cur-pos");
await page.click('#cur-pos [data-v="left"]');
await page.waitForTimeout(300);
ok((await api("GET", "/currency")).data.position === "left", "Amounts: symbol before, saved");
ok(/\$1,234\.56/.test(await page.textContent("#set-pane .w-glf")), "the sample under the rows follows it");
await page.click('#cur-pos [data-v="right"]');
await page.click('#set-pane a[data-pane="region/currency"]'); await page.waitForSelector("#cur-q");
await page.click('#set-pane [data-act="pick-cur"][data-name="EUR"]'); await toastText(/EUR/);
await page.click("#set-head .w-sback"); await page.waitForSelector("#cur-pos");
await page.click('#set-pane a[data-pane="region/week"]');
await page.click('#set-pane [data-act="pick-week"][data-v="sun"]');
await page.waitForFunction(() => location.pathname === "/settings/region");
ok((await api("GET", "/config")).data.week_start === "sun", "Week starts on Sunday saved, and back to Language & Region");
ok(/Sunday/.test(await paneText()), "the row shows it");
await page.click('#set-pane a[data-pane="region/week"]');
await page.click('#set-pane [data-act="pick-week"][data-v="mon"]');
await page.waitForFunction(() => location.pathname === "/settings/region");
await page.click('#set-pane a[data-pane="region/timezone"]');
await page.fill("#tz-q", "madrid");
await page.click('#set-pane [data-act="pick-tz"][data-v="Europe/Madrid"]');
await toastText(/Europe\/Madrid/);
ok((await api("GET", "/config")).data.timezone === "Europe/Madrid", "time zone saved");
await page.click('#set-pane a[data-pane="region/timezone"]');
await page.click('#set-pane [data-act="pick-tz"][data-v=""]');
await toastText(/follows the server/);
section("Import and export");
await pane("import");
const ledger = (await api("GET", "/ledger")).data;
const col = Object.fromEntries(ledger.columns.map((c, i) => [c, i]));
const typed = ledger.rows.filter((r) => !r[col.imported] && r[col.type] === "e" && r[col.date] <= "2026-10-20");
const t1 = typed.find((r) => /mercadona/i.test(r[col.name])) || typed[0];
const t2 = typed.find((r) => r !== t1 && r[col.date] !== t1[col.date] && !typed.some((x) => x !== r && x[col.date] === r[col.date] && x[col.cents] === r[col.cents]));
const dmy = (iso) => iso.slice(8, 10) + "/" + iso.slice(5, 7) + "/" + iso.slice(0, 4);
const money = (c) => (c / 100).toFixed(2).replace(".", ",");
// v1 file: name,category,tags,amount,type,date
const v1 = "name,category,tags,amount,type,date\nPiano lesson,Education,\"music,kids\",45.00,expense,2026-10-02\nBook fair,Entertainment,,12.50,expense,2026-10-03\nRefund,Income,,30.00,income,2026-10-04\n";
writeFileSync(join(TMP, "v1.csv"), v1);
await page.setInputFiles("#imp-file", join(TMP, "v1.csv"));
await page.waitForSelector(".w-imp-map");
ok(/Wally v1 file/.test(await paneText()), "the v1 format is recognised");
ok((await page.$$eval(".w-imp-map select", (s) => s.map((x) => x.value))).join() === "name,category,tags,amount,type,date", "its columns are mapped");
ok(await page.inputValue("#imp-datefmt") === "ymd", "and its dates read as YYYY-MM-DD");
await page.click('[data-act="preview"]');
await page.waitForSelector(".w-imp-prev");
ok(/3 new/.test(await page.textContent(".w-imp-counts")), "preview: 3 new");
await page.click('[data-act="commit"]');
await toastText(/Imported 3/);
const piano = (await api("GET", "/transactions/search?text=Piano")).data;
ok(piano.length === 1 && piano[0].tags.join() === "music,kids" && piano[0].category === "Education", "v1 rows are saved with category and tags");
ok((await api("GET", "/transactions/search?text=Refund")).data.some((x) => x.type === "income"), "the income row is income");
await page.click('[data-act="reset"]');
await page.setInputFiles("#imp-file", join(TMP, "v1.csv"));
await page.click('[data-act="preview"]');
await page.waitForSelector(".w-imp-prev");
ok(/3 imported before/.test(await page.textContent(".w-imp-counts")), "importing it again: all skipped");
ok((await page.textContent("#imp-foot")).includes("Nothing new to import"), "and there is nothing to commit");
await page.click('#imp-foot [data-act="reset"]');
// A bank file: semicolons, DD/MM/YYYY, signed amounts, one typed by hand, one possible match, one new, one bad
const bank = "Fecha;Concepto;Importe\n" +
dmy(t1[col.date]) + ";" + (t1[col.name] || "SHOP").toUpperCase() + " 4521 BCN;-" + money(t1[col.cents]) + "\n" +
dmy(t2[col.date]) + ";ZXQ PAYMENT 7781;-" + money(t2[col.cents]) + "\n" +
"13/10/2026;BIZUM FROM JO;+60,00\n" +
"31/02/2026;BROKEN DATE;-5,00\n" +
"14/10/2026;NEW SHOP 22;-17,35\n";
writeFileSync(join(TMP, "bank.csv"), bank);
await page.setInputFiles("#imp-file", join(TMP, "bank.csv"));
await page.waitForSelector(".w-imp-map");
ok((await page.$$eval(".w-imp-map select", (s) => s.map((x) => x.value))).join() === "date,name,amount", "bank columns are guessed (Fecha, Concepto, Importe)");
ok(await page.inputValue("#imp-datefmt") === "dmy", "DD/MM/YYYY is detected");
await page.click('[data-act="preview"]');
await page.waitForSelector(".w-imp-prev");
const counts = await page.textContent(".w-imp-counts");
ok(/1 already added/.test(counts) && /1 possible match/.test(counts) && /2 new/.test(counts), "preview counts: 1 matched, 1 possible, 2 new (" + counts.trim() + ")");
ok(/1 row can’t be read/.test(await paneText()), "the bad date is listed as unreadable");
ok(/Decide 1 possible match/.test(await page.textContent("#imp-foot")) && await page.isDisabled('#imp-foot [data-act="commit"]'), "you must decide on the possible match first");
await page.selectOption(".w-imp-prev select[data-dec]", "match");
await page.click('#imp-filter [data-v="all"]');
ok(/\+/.test(await page.textContent(".w-imp-prev tbody")), "positive amounts are income");
// Change the category and the person of the new BIZUM row
const bizum = page.locator(".w-imp-prev tr", { hasText: "BIZUM" });
await bizum.locator("select[data-cat]").selectOption("Income");
await bizum.locator("select[data-who]").selectOption("2");
ok(/1 row has no category/.test(await paneText()), "rows without a category are counted");
await page.selectOption("#imp-fill", "Shopping");
await page.click('#imp-foot [data-act="commit"]');
await toastText(/Imported 2, kept 2 once/);
const bz = (await api("GET", "/transactions/search?text=BIZUM")).data;
ok(bz.length === 1 && bz[0].category === "Income" && bz[0].person_id === 2 && bz[0].type === "income", "the row's category and person were used");
ok((await api("GET", "/transactions/search?text=NEW%20SHOP")).data[0].category === "Shopping", "the rest got the category given to all");
const t1after = (await api("GET", "/transactions/id/" + t1[col.id])).data[0];
ok(t1after && t1after.source && /4521/.test(t1after.source.name), "the matched one keeps the bank's name as its source");
await page.click('[data-act="reset"]');
await page.setInputFiles("#imp-file", join(TMP, "bank.csv"));
await page.click('[data-act="preview"]');
await page.waitForSelector(".w-imp-prev");
ok(/4 imported before/.test(await page.textContent(".w-imp-counts")), "re-importing the bank file skips every row");
await page.click('#imp-foot [data-act="reset"]');
await shot("import-done");
// Export
const [dl] = await Promise.all([page.waitForEvent("download"), page.click('[data-act="export"]')]);
const csv = readFileSync(await dl.path(), "utf8").replace(/^/, "");
const lines = csv.trim().split(/\r?\n/);
const nTx = (await api("GET", "/ledger")).data.rows.length;
ok(lines[0] === "date,name,category,amount,type,tags,person,paid_by,shares,note", "export header");
ok(lines.length - 1 === nTx, "export has every transaction (" + nTx + ")");
ok(/^wally-\d{4}-\d\d-\d\d\.csv$/.test(dl.suggestedFilename()), "export file name");
ok(lines.some((l) => /Piano lesson,Education,45\.00,expense,"music,kids"/.test(l)), "tags are written as v1 does");
ok(lines.some((l) => /,shared,(Alex|Ana),Alex:\d+\.\d\d;Ana:\d+\.\d\d,/.test(l)), "shared rows carry payer and shares");
// The export imports back: everything is either already there or imported before
writeFileSync(join(TMP, "export.csv"), csv);
await page.setInputFiles("#imp-file", join(TMP, "export.csv"));
await page.waitForSelector(".w-imp-map");
ok(/Wally export/.test(await paneText()), "a Wally export is recognised");
await page.click('[data-act="preview"]');
await page.waitForSelector(".w-imp-prev", { timeout: 20000 });
// Only the two rows matched to the bank file show as new: they carry the bank's fingerprint now
const reCounts = (await page.textContent(".w-imp-counts")).trim();
ok(/^[0-2] new/.test(reCounts), "re-importing the export adds nothing typed or imported before (" + reCounts + ")");
await page.click('#imp-foot [data-act="reset"]');
/* ======================================================================= */
section("API keys");
await pane("keys");
await page.click('#set-pane [data-act="create"]');
ok(await page.isDisabled("#dlg [data-dlg-ok]"), "New Key waits for a name");
await page.fill("#key-name", "Test script");
await dlgOk();
await page.waitForSelector("#key-raw");
const rawKey = (await page.textContent("#key-raw")).trim();
ok(/^wly_[0-9a-f]{64}$/.test(rawKey), "the new key is shown once");
await page.click("#dlg [data-copy]");
await toastText(/Key copied/);
await dlgOk(); await dlgClosed();
const viaKey = await fetch(BASE + "/api/transactions", { method: "POST", headers: { "X-API-Key": rawKey, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Shortcut coffee", amount: "2.20", category: "Food", person_id: 1 }) });
const viaKeyJson = await viaKey.json();
ok(viaKey.status === 200 && viaKeyJson.created_by === 1, "the key adds a transaction, added by its owner");
await page.reload(); await page.waitForSelector("#set-pane .w-gl");
// The keys arrive after the pane is drawn
await page.waitForFunction(() => /Test script/.test(document.getElementById("set-pane").textContent) && /\d/.test(document.querySelector('#set-nav a[data-pane="keys"] .v').textContent), null, { timeout: 8000 }).catch(() => {});
ok(/Test script/.test(await paneText()) && /last used just now/.test(await paneText()), "the list shows what it can do and when it was used");
ok(/1/.test(await page.textContent('#set-nav a[data-pane="keys"] .v')), "the pane list counts keys");
await page.click('#set-pane [data-act="ios"]');
ok((await dialog().textContent()).includes(BASE + "/api/transactions") && (await dialog().textContent()).includes("X-API-Key"), "iPhone steps show the exact request");
await dlgOk(); await dlgClosed();
await page.click('#set-pane [data-act="android"]');
ok((await page.textContent("#sc-req")).includes('"amount": "{amount}"'), "Android request with variables");
await dlgOk(); await dlgClosed();
await openRow('#set-pane [data-act="key"]');
ok(/Alex/.test(await paneText()) && /Read and write/.test(await paneText()), "a key's page: what it can do and whose it is");
await page.click('#set-pane [data-act="delete"]');
await page.waitForSelector("#dlg .w-alert");
await dlgOk(); await dlgClosed();
ok((await api("GET", "/api-keys")).data.length === 0, "a key is deleted after confirming");
/* ======================================================================= */
section("Appearance");
await pane("appearance");
ok(!/Week starts on|Time zone|About/.test(await paneText()), "only Theme");
await page.click('#ap-theme [data-v="dark"]');
ok(await page.getAttribute("html", "data-theme") === "dark", "Dark applies at once");
await page.waitForFunction(() => /Dark/.test(document.querySelector('#set-nav a[data-pane="appearance"] .v').textContent));
ok(true, "the pane list says Dark");
await shot("appearance-dark");
await page.click('#ap-theme [data-v="light"]');
ok(await page.getAttribute("html", "data-theme") === "light", "Light");
await page.click('#ap-theme [data-v="auto"]');
ok(await page.getAttribute("html", "data-theme") === null, "Auto follows the device");
/* ======================================================================= */
section("Language");
await pane("region");
await page.click('#set-pane [data-act="lang"]');
await page.waitForSelector(".w-popmenu");
const langRow = await page.locator('#set-pane [data-act="lang"]').boundingBox(), langMenu = await page.locator(".w-popmenu").boundingBox();
const langRoom = langRow.x + langRow.width + 8 + langMenu.width <= 1440 - 8;
ok(langRoom ? langMenu.x >= langRow.x + langRow.width && langMenu.y < langRow.y + langRow.height : langMenu.y >= langRow.y + langRow.height && langMenu.x + langMenu.width <= 1440, "the language menu opens beside its row, or under it when there's no room");
ok(await page.locator(".w-popmenu [data-lang]").count() === 13 && !/needs review/.test(await page.textContent(".w-popmenu")), "the language is a menu of 13, each in its own name");
await page.click('.w-popmenu [data-lang="es"]');
await page.waitForFunction(() => document.documentElement.lang === "es", null, { timeout: 8000 });
await page.waitForSelector("#set-pane .w-gl");
ok((await api("GET", "/language")).data.language === "es", "Español saved, and Wally reloaded in it");
await page.click('#set-pane [data-act="lang"]');
await page.click('.w-popmenu [data-lang="ar"]');
await page.waitForFunction(() => document.documentElement.dir === "rtl", null, { timeout: 8000 });
await page.waitForSelector("#set-pane .w-gl");
const navBox = await page.locator("#set-nav").boundingBox(), paneBox = await page.locator("#set-pane").boundingBox();
ok(navBox.x > paneBox.x, "Arabic mirrors the layout (panes on the right)");
await shot("arabic");
await page.click('#set-pane [data-act="lang"]');
await page.click('.w-popmenu [data-lang="en"]');
await page.waitForFunction(() => document.documentElement.lang === "en" && document.documentElement.dir === "ltr", null, { timeout: 8000 });
await page.waitForSelector("#set-pane .w-gl");
/* ======================================================================= */
section("Sign-in & Privacy");
/* Sign-in and Privacy are one page, /settings/security; two-step, the kit, browsers and the server open their own pages */
async function secOpen(sub) {
await page.goto(BASE + "/settings/security" + (sub ? "/" + sub : ""), { waitUntil: "networkidle" });
await page.waitForSelector("#set-pane .w-sec", { timeout: 10000 });
await page.waitForTimeout(150);
}
const twoStepRow = 'a[data-pane="security/two-step"]';
await secOpen();
ok(/Off\. Anyone who can reach this address/.test(await paneText()) && !/Change household password/.test(await paneText()), "off: only “Ask for a password”, no password rows");
ok(/End-to-end encryption/.test(await paneText()) && await page.isDisabled("#pv-toggle"), "Privacy is on the same page; encryption waits for sign-in");
await page.click("#si-toggle");
await page.waitForSelector("#so-pw");
ok(/Everyone in the household uses this password/.test(await dialog().textContent()), "turning on asks for one password, for everyone");
await page.fill("#so-pw", "short");
await page.fill("#so-rep", "short");
ok(await page.isDisabled("#dlg [data-dlg-ok]") && (await page.textContent("#pw-hint")) === "At least 8 characters", "Turn On waits for at least 8 characters");
await page.fill("#so-pw", "alexpassword");
await page.fill("#so-rep", "alexpassw0rd");
ok(await page.isDisabled("#dlg [data-dlg-ok]") && (await page.textContent("#pw-hint")) === "The two passwords are different.",
"the two passwords must match: once the second is typed out, the line under them says they differ");
await page.fill("#so-rep", "alexpassword");
ok((await page.textContent("#pw-hint")) === "At least 8 characters" && !(await page.isDisabled("#dlg [data-dlg-ok]")), "once they match, Turn On lights up");
await dlgOk();
// No Emergency Kit comes with it: optional without encryption
ok(await page.locator("#kit-saved").count() === 0, "turning on shows no kit: optional without encryption");
await toastText(/Sign-in is on/);
let status = (await api("GET", "/auth/status")).data;
ok(status.signin && status.me && status.me.id === 1 && status.two_step === false, "sign-in is on and this browser stays signed in as Alex");
await page.reload(); await page.waitForSelector("#set-pane .w-sec");
ok(/On/.test(await page.textContent('#set-nav a[data-pane="security"] .v')), "still in after reload (session cookie)");
await page.waitForFunction(() => /Signed-in browsers\s*1/.test(document.getElementById("set-pane").textContent));
let txt = await paneText();
ok(/Change household password/.test(txt) && await page.locator('[data-act="password"]').count() === 1, "a Change household password row");
ok(/Two-step sign-in\s*Off/.test(txt) && await page.locator(twoStepRow).count() === 1, "a Two-step sign-in row, Off");
ok(!/Recovery codes/.test(txt), "no recovery codes while two-step is off");
ok(/Signed-in browsers/.test(txt) && await page.locator('[data-act="signout"]').count() === 1, "Browsers: Signed-in browsers, and Sign out of this browser");
ok(/If you forget the password/.test(txt) && /Emergency Kit\s*None/.test(txt) && status.kit === null, "If you forget the password: the Emergency Kit, None yet");
ok(/Turning it on makes a new Emergency Kit/.test(txt) && !await page.isDisabled("#pv-toggle"), "encryption can be turned on now, in one footnote");
await page.click('a[data-pane="security/browsers"]');
await page.waitForFunction(() => /Alex · Last seen/.test(document.getElementById("set-pane").textContent));
ok(/\/settings\/security\/browsers$/.test(page.url()) && /Sign-in & Privacy/.test(await page.textContent("#set-head")), "Signed-in browsers opens its own page, with a way back");
ok(await page.isDisabled('[data-act="others"]'), "Sign out of other browsers waits until there are others");
await page.click('#set-head a[data-pane="security"]');
await page.waitForSelector('a[data-pane="security/kit"]');
await page.click('a[data-pane="security/kit"]');
await page.waitForSelector('[data-act="kit"]');
txt = await paneText();
ok(/Optional without encryption/.test(txt) && /Make a kit/.test(txt) && /whoever runs the server can turn sign-in off/.test(txt) && !/password\.txt/.test(txt), "Emergency Kit page: optional, Make a kit, and what happens without one");
// Made here, with the password: the household's Emergency Kit, once
await page.click('[data-act="kit"]');
await page.waitForSelector("#kit-pw");
ok(/Make an Emergency Kit\?/.test(await dialog().textContent()), "Make a kit asks for the password first");
await page.fill("#kit-pw", "alexpassword");
await dlgOk();
await page.waitForSelector("#kit-saved", { timeout: 10000 });
ok(/this recovery key gets you back in/.test(await dialog().textContent()) && await page.locator("#dlg svg.w-qr").count() === 0, "then shows the household's Emergency Kit: the key alone");
const kit1 = (await page.locator("#dlg .w-kitkey").innerText()).replace(/\s+/g, "-");
ok(/^([0-9A-Z]{4}-){11}[0-9A-Z]{4}$/.test(kit1), "a 48-character recovery key, in 12 groups");
ok(await page.isDisabled("#dlg [data-dlg-ok]"), "Done waits until it is saved");
const [kitDl] = await Promise.all([page.waitForEvent("download"), page.click('#dlg [data-act="kit-pdf"]')]);
const kitPdf = readFileSync(await kitDl.path(), "latin1");
ok(kitDl.suggestedFilename() === "wally-emergency-kit.pdf" && kitPdf.startsWith("%PDF-") && kit1.split("-").every((g) => kitPdf.includes(g)), "the kit downloads as a PDF, with the key in it as text");
ok(!/\b3 Tr\b/.test(kitPdf) && kitPdf.includes("/Courier-Bold"), "the key is real, visible text, so any viewer can select it");
await page.click("#kit-saved");
await dlgOk(); await dlgClosed();
await toastText(/^Emergency Kit saved/);
status = (await api("GET", "/auth/status")).data;
ok(typeof status.kit === "string", "status says when the kit was made");
await page.waitForFunction(() => /Made /.test(document.getElementById("set-pane").textContent) && /Make a new kit/.test(document.getElementById("set-pane").textContent));
ok(/Without the kit, whoever runs the server/.test(await paneText()), "with a kit: when it was made, Make a new kit, and what happens without it");
const anon = await fetch(BASE + "/api/ledger");
ok(anon.status === 401, "without the cookie, the API now asks to sign in");
ok((await fetch(BASE + "/api/ledger", { headers: { "X-API-Key": rawKey } })).status === 401, "the deleted API key no longer works");
// Adding a person with sign-in on: a name, nothing else
await open("/settings/people");
await page.click('[data-act="add"]');
await page.waitForSelector("#np-name");
ok(/They sign in with the household password\./.test(await dialog().textContent()), "Add a Person: new people sign in with the household password");
ok(await page.locator("#dlg input").count() === 1 && await page.locator('#dlg input[type="password"]').count() === 0, "Add a person asks only for a name");
await page.fill("#np-name", "Lee");
await dlgOk(); await dlgClosed();
await toastText(/Added Lee/);
const leeId = (await api("GET", "/people")).data.find((p) => p.name === "Lee").id;
const lee = await browser.newContext();
const leeIn = await lee.request.post(BASE + "/api/login", { data: { person_id: leeId, password: "alexpassword" } });
ok(leeIn.ok() && (await leeIn.json()).code_needed === false, "Lee signs in with the household password");
ok((await (await lee.request.get(BASE + "/api/auth/status")).json()).me.name === "Lee", "as Lee");
await openRow(`#set-pane a[data-pane="people/${leeId}"]`);
await page.click('#set-pane [data-act="remove"]');
await page.waitForSelector("#dlg [data-dlg-ok]");
ok(/They still know the household password/.test(await dialog().textContent()), "removing someone says they still know the password");
await dlgOk(); await dlgClosed();
await toastText(/Removed Lee/);
ok((await lee.request.get(BASE + "/api/ledger", { failOnStatusCode: false })).status() === 401, "and their sign-in ends");
await lee.close();
await secOpen();
// Change the household password: every other browser is signed out
const anaCtx = await browser.newContext();
ok((await anaCtx.request.post(BASE + "/api/login", { data: { person_id: 2, password: "alexpassword" } })).ok(), "Ana signs in elsewhere with the same password");
await page.click('[data-act="password"]');
await page.fill("#pw-cur", "wrongwrong"); await page.fill("#pw-new", "alexpassword2"); await page.fill("#pw-rep", "alexpassword2");
await expectHttpError(async () => { await dlgOk(); ok(/not right/.test(await dlgError()), "a wrong current password is refused"); });
await page.fill("#pw-cur", "alexpassword"); await page.fill("#pw-rep", "different1");
ok(await page.locator("#dlg [data-dlg-ok]").isDisabled() && (await page.textContent("#pw-hint")) === "At least 8 characters", "Change waits, and says nothing while the second is still being typed");
await page.fill("#pw-rep", "alexpassword3");
ok((await page.textContent("#pw-hint")) === "The two new passwords are not the same.", "typed out and different: it says so");
await page.fill("#pw-rep", "alexpassword2");
ok(!(await page.locator("#dlg [data-dlg-ok]").isDisabled()), "Change lights up once they match");
await dlgOk(); await dlgClosed();
await toastText(/Password changed/);
ok((await anaCtx.request.get(BASE + "/api/ledger", { failOnStatusCode: false })).status() === 401, "changing the password signs Ana's browser out");
await anaCtx.close();
ok((await api("GET", "/ledger")).status === 200, "and keeps this one in");
// From now on the browser sends a login key made from the password, never the password
const loginKey = (pw) => page.evaluate(async (pw) => {
const kdf = await fetch("/api/vault/kdf").then((r) => r.json());
return (await (await import("/js/vault/crypto.js")).fromPassword(pw, kdf.salt)).login;
}, pw);
// A new Emergency Kit: the password, then the new key; the old one stops working
const recoverWith = async (key) => {
const c = await browser.newContext();
const r = await c.request.post(BASE + "/api/kit/recover", { data: { person_id: 2, key }, failOnStatusCode: false });
await c.close();
return r.status();
};
ok(await expectHttpError(() => recoverWith(kit1)) === 200, "the first kit works");
await secOpen("kit");
await page.click('[data-act="kit"]');
await page.waitForSelector("#kit-pw");
await page.fill("#kit-pw", "wrongwrong");
await expectHttpError(async () => { await dlgOk(); ok(/not right/.test(await dlgError()), "a new kit asks for the password, and a wrong one is refused"); });
await page.fill("#kit-pw", "alexpassword2");
await dlgOk();
await page.waitForSelector("#kit-saved", { timeout: 10000 });
const kit2 = (await page.locator("#dlg .w-kitkey").innerText()).replace(/\s+/g, "-");
ok(kit2 !== kit1, "a new key is shown");
await page.click("#kit-saved");
await dlgOk(); await dlgClosed();
await toastText(/New Emergency Kit saved/);
ok(await expectHttpError(() => recoverWith(kit1)) === 401, "the old kit stops working");
ok(await expectHttpError(() => recoverWith(kit2)) === 200, "the new one works");
await secOpen();
// Two-step: set up with a real QR and secret, confirmed with a code worked out here and the password
await page.click(twoStepRow);
await page.waitForSelector("#ts-secret", { state: "attached" });
const qrPath = await page.getAttribute("#dlg svg.w-qr path", "d");
ok(qrPath && qrPath.length > 500, "a real QR code is drawn");
const secret = (await page.textContent("#ts-secret")).replace(/\s/g, "");
ok(/^[A-Z2-7]{32}$/.test(secret) && !(await page.isVisible("#ts-secret")), "the key to type in waits behind Can’t scan?");
await page.click("#dlg [data-show]");
ok(await page.isVisible("#ts-secret") && !(await page.isVisible("#dlg [data-show]")), "and shows when asked for");
ok(/on each person’s phone/.test(await dialog().textContent()) && await page.locator("#dlg #ts-pw").count() === 1, "one code for the household, and it asks for the password");
await page.fill("#ts-code", totp(secret));
await page.waitForTimeout(300);
ok(!(await page.locator("#rc-codes").count()) && await dialog().isVisible(), "six digits alone don't send it");
await page.fill("#ts-pw", "wrongwrong");
await expectHttpError(async () => { await dlgOk(); ok(/password is not right/.test(await dlgError()), "a wrong password is refused"); });
await page.fill("#ts-code", "000000");
await page.fill("#ts-pw", "alexpassword2");
await page.evaluate(() => { const e = document.getElementById("dlg-err"); e.hidden = true; e.textContent = ""; });
await expectHttpError(async () => { await dlgOk(); ok(/code is not right/.test(await dlgError()), "a wrong code is refused"); });
await page.fill("#ts-code", totp(secret));
await dlgOk();
await page.waitForSelector("#rc-codes", { timeout: 8000 });
const codes = await page.$$eval("#rc-codes span", (s) => s.map((x) => x.textContent));
ok(codes.length === 10, "ten recovery codes are shown");
const [rcDl] = await Promise.all([page.waitForEvent("download"), page.click("#dlg [data-save]")]);
ok(readFileSync(await rcDl.path(), "utf8").includes(codes[0]), "recovery codes download");
await dlgOk(); await dlgClosed();
await toastText(/Two-step sign-in is on/);
await page.waitForFunction(() => /\/settings\/security\/two-step$/.test(location.pathname) && /Codes left\s*10 of 10/.test(document.getElementById("set-pane").textContent));
ok(true, "two-step opens its own page: 10 of 10 codes left");
txt = await paneText();
ok(/the same on everyone’s phone/.test(txt) && await page.locator('[data-act="2show"]').count() === 1 && await page.locator("#ts-toggle").count() === 1, "two-step on: Add a phone, and a switch to turn it off");
ok((await api("GET", "/auth/status")).data.two_step === true, "status says two-step is on");
// New codes replace the old ones
await page.click('[data-act="codes"]');
await page.fill("#ts-pw", "alexpassword2");
await dlgOk();
await page.waitForSelector("#rc-codes");
const codes2 = await page.$$eval("#rc-codes span", (s) => s.map((x) => x.textContent));
ok(codes2.length === 10 && codes2[0] !== codes[0], "new recovery codes");
await dlgOk(); await dlgClosed();
// Ana signs in on a phone with a recovery code; Settings lists it, with her name, then signs it out
const other = await browser.newContext({ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1" });
const l1 = await other.request.post(BASE + "/api/login", { data: { person_id: 2, password: await loginKey("alexpassword2") } });
ok((await l1.json()).code_needed === true, "signing in elsewhere asks for the code");
const oldCode = await other.request.post(BASE + "/api/login/code", { data: { recovery_code: codes[0] }, failOnStatusCode: false });
ok(oldCode.status() === 401, "an old recovery code no longer works");
const l2 = await other.request.post(BASE + "/api/login/code", { data: { recovery_code: codes2[0] } });
ok(l2.status() === 200, "a new recovery code signs in");
await page.reload(); await page.waitForSelector("#set-pane .w-sec");
await page.waitForFunction(() => /Codes left\s*9 of 10/.test(document.getElementById("set-pane").textContent));
ok(true, "9 codes are left");
await secOpen("browsers");
await page.waitForFunction(() => /Safari on iPhone/.test(document.getElementById("set-pane").textContent));
txt = await paneText();
ok(/This browser/.test(txt) && /Safari on iPhone\s*Ana · Last seen/.test(txt), "Signed-in browsers lists both, and the phone shows it is Ana's");
await shot("signin-on");
await page.click('[data-act="others"]');
await page.waitForSelector("#dlg .w-alert");
ok(/Sign out of 1 other browser\?/.test(await dialog().textContent()), "Sign out of other browsers asks first, with the number");
await dlgOk();
await toastText(/Signed out of 1 other browser/);
ok((await other.request.get(BASE + "/api/ledger", { failOnStatusCode: false })).status() === 401, "Sign out others ends the other session");
await other.close();
// Two-step off: the switch on its page
await secOpen("two-step");
await page.click("#ts-toggle");
await page.fill("#ts-pw", "alexpassword2");
await dlgOk(); await dlgClosed();
await toastText(/Two-step sign-in is off/);
ok((await api("GET", "/auth/status")).data.two_step === false, "two-step off");
await page.waitForFunction(() => location.pathname === "/settings/security" && /Two-step sign-in\s*Off/.test(document.getElementById("set-pane").textContent));
ok(!/Recovery codes/.test(await paneText()), "back on Sign-in & Privacy, and the recovery codes are gone");
// Turn sign-in off: asks the password, and removes it
await page.click("#si-toggle");
await page.waitForSelector("#sf-pw");
ok(!(await page.isDisabled("#dlg [data-dlg-ok]")), "Turn Off stays lit, as in an iOS alert");
await dlgOk();
ok(/Enter your password first/.test(await dlgError()) && await dialog().isVisible(), "with the field empty it asks for the password");
await page.fill("#sf-pw", "nope");
await expectHttpError(async () => { await dlgOk(); ok(/not right/.test(await dlgError()), "turning off with a wrong password is refused"); });
await page.fill("#sf-pw", "alexpassword2");
await dlgOk(); await dlgClosed();
await toastText(/Sign-in is off/);
const offStatus = await fetch(BASE + "/api/auth/status").then((r) => r.json());
ok(offStatus.signin === false && offStatus.kit === null, "sign-in is off, and there is no kit");
ok(await recoverWith(kit2) === 400, "the kit no longer opens anything");
await page.reload(); await page.waitForSelector("#set-pane .w-sec");
ok(/Off/.test(await page.textContent('#set-nav a[data-pane="security"] .v')), "and Wally still opens in this browser");
await page.click("#si-toggle");
await page.waitForSelector("#so-pw");
ok(true, "turning it on again asks for a new password");
await dlgCancel();
/* ======================================================================= */
section("Widths, dark, and the phone");
for (const [w, h] of [[1440, 900], [1100, 800], [900, 800]]) {
await page.setViewportSize({ width: w, height: h });
await open("/settings/people");
const over = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
ok(over <= 0, w + " px: no sideways scrolling");
await shot("people-" + w);
}
const P = await newPage(BASE, { viewport: { width: 390, height: 844 }, dark: true });
await P.page.goto(BASE + "/settings", { waitUntil: "networkidle" });
await P.page.waitForSelector(".w-setlist");
ok(await P.page.locator(".w-setlist .w-gr").count() === 8, "phone: Settings is a list of panes in five groups");
await shot("phone-list", P.page);
await P.page.click('.w-setlist [data-pane="security"]');
await P.page.waitForFunction(() => location.pathname === "/settings/security" && document.querySelector("#set-pane .w-gl"));
ok(await P.page.locator("#set-nav").count() === 0, "a pane opens full screen, without the desktop pane list");
ok(await P.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "390 px: no sideways scrolling");
await shot("phone-security", P.page);
for (const id of ["people", "categories/Rent", "tags/coffee", "import", "keys", "appearance", "region", "region/currency"]) {
await P.page.goto(BASE + "/settings/" + id, { waitUntil: "networkidle" });
await P.page.waitForSelector("#set-pane .w-gl, #set-pane .w-group");
ok(await P.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "390 px " + id + ": no sideways scrolling");
await shot("phone-" + id.replace("/", "-"), P.page);
}
ok((await P.page.textContent("#set-title")) === "Currency" && /Language & Region/.test(await P.page.textContent("#set-head .w-sback")), "phone: a pushed page has ‹ back and a large title");
await P.page.goto(BASE + "/settings/people", { waitUntil: "networkidle" });
await P.page.waitForSelector("#set-pane .w-gl");
await P.page.click("#set-head .w-sback");
await P.page.waitForSelector(".w-setlist");
ok(P.page.url().endsWith("/settings"), "Back goes to the list");
await P.page.goto(BASE + "/settings/people/2", { waitUntil: "networkidle" });
await P.page.waitForSelector("#set-pane .w-shero");
await P.page.click('#set-pane [data-act="remove"]');
await P.page.waitForSelector("#dlg .w-alert");
const dlgBox = await P.page.locator("#dlg .w-alert").boundingBox();
ok(dlgBox.y + dlgBox.height > 780 && /Remove Ana/.test(await P.page.textContent("#dlg [data-dlg-ok]")), "phone: removing is an action sheet at the bottom, Remove Ana in it");
await P.page.click("#dlg [data-dlg-x]");
// The large title sits right on its first group; a category's delete is a short sheet with where its transactions go as a row
await P.page.goto(BASE + "/settings/tags", { waitUntil: "networkidle" });
await P.page.waitForSelector("#set-pane .w-gl");
const gap = await P.page.evaluate(() => document.querySelector("#set-pane .w-gl").getBoundingClientRect().top - document.querySelector("#set-title").getBoundingClientRect().bottom);
ok(gap >= 4 && gap <= 16, "phone: the title sits right above its first group (" + Math.round(gap) + " px)");
await P.page.goto(BASE + "/settings/categories/Rent", { waitUntil: "networkidle" });
await P.page.waitForSelector("#set-pane .w-shero");
await P.page.click('#set-pane [data-act="delete"]');
await P.page.waitForSelector("#dlg .w-alert.w-hsheet #cd-to");
const sheetBox = await P.page.locator("#dlg .w-alert").boundingBox();
ok(sheetBox.x === 0 && sheetBox.width === 390 && sheetBox.y + sheetBox.height >= 843 && /Delete Rent/.test(await P.page.textContent("#dlg [data-dlg-ok]")), "phone: deleting a category is a sheet from the bottom, Delete Rent in it");
await P.page.click("#dlg [data-dlg-x]");
await P.page.setViewportSize({ width: 1100, height: 800 });
await P.page.waitForSelector("#set-nav");
ok(true, "widening the window shows the pane list beside the pane");
await shot("dark-1100", P.page);
await P.ctx.close();
/* ======================================================================= */
if (EMPTY) {
section("A one-person household, empty");
const E = await newPage(EMPTY);
const eapi = apiOf(E);
const me = await eapi("POST", "/people", { name: "Robin" });
await E.ctx.addInitScript((id) => localStorage.setItem("wally.person", id), String(me.data.id));
await E.page.goto(EMPTY + "/settings/people", { waitUntil: "networkidle" });
await E.page.waitForSelector("#set-pane .w-gl");
ok(await E.page.locator("#set-act [data-act=edit]").count() === 0, "one person: nothing to reorder");
await E.page.goto(EMPTY + "/settings/people/" + me.data.id, { waitUntil: "networkidle" });
await E.page.waitForSelector("#set-pane .w-shero");
ok(!(await E.page.locator('#set-pane [data-act="remove"], #set-pane [data-act="switch"]').count()), "one person: no removing and no Switch");
for (const [id, re] of [["tags", /No tags yet/], ["keys", /Create a key/]]) {
await E.page.goto(EMPTY + "/settings/" + id, { waitUntil: "networkidle" });
await E.page.waitForFunction((src) => new RegExp(src).test(document.getElementById("set-pane").textContent), re.source);
ok(true, id + ": empty state");
}
await E.page.goto(EMPTY + "/settings/import", { waitUntil: "networkidle" });
await E.page.waitForSelector("#set-pane .w-group");
ok(await E.page.isDisabled('[data-act="export"]'), "nothing to export yet");
await E.ctx.close();
}
await browser.close();
section("Console");
ok(errors.length === 0, "no console errors" + (errors.length ? ": " + errors.slice(0, 5).join(" | ") : ""));
console.log(failures ? `\n${failures} FAILED` : "\nAll passed");
process.exit(failures ? 1 : 0);