// Journeys across pages: links between them, figures that follow a change, keys after every page. // BASE=http://localhost:8891 node flows.mjs (a fresh DEMO server) import { readFileSync } from "node:fs"; import { chromium } from "playwright"; const BASE = process.env.BASE || "http://localhost:8891"; let failed = 0; const ok = (cond, msg) => { console.log((cond ? " ok " : " FAIL ") + msg); if (!cond) failed++; }; const money = (s) => Number(String(s).replace(/[^\d.,-]/g, "").replace(/,/g, "")); const browser = await chromium.launch(); async function open(width, height) { const ctx = await browser.newContext({ viewport: { width, height }, serviceWorkers: "block" }); await ctx.addInitScript(() => localStorage.setItem("wally.person", "1")); const page = await ctx.newPage(); const errors = []; page.on("pageerror", (e) => errors.push(e.message)); page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); return { ctx, page, errors }; } { console.log("Opening Wally"); const { ctx, page, errors } = await open(1440, 900); // index.html asks for each page's scripts up front: the list has to be what the page really imports const web = new URL("../../web/js/", import.meta.url); const closure = (file, seen = new Set()) => { if (seen.has(file)) return seen; seen.add(file); for (const m of readFileSync(new URL(file, web), "utf8").matchAll(/from\s+"(\.[^"]+)"/g)) closure(new URL(m[1], new URL(file, web)).pathname.split("/web/js/")[1], seen); return seen; }; for (const [path, file] of [["/", "pages/dashboard.js"], ["/transactions", "pages/transactions.js"], ["/recurring", "pages/recurring.js"], ["/settings/people", "pages/settings.js"], ["/review", "pages/review.js"]]) { await page.goto(BASE + path, { waitUntil: "networkidle" }); const listed = await page.$$eval('link[rel="modulepreload"]', (ls) => ls.map((l) => new URL(l.href).pathname.slice(4)).sort()); const real = [...closure(file), "ui.js", "shell.js", "core.js"].filter((f, i, a) => a.indexOf(f) === i).sort(); ok(JSON.stringify(listed) === JSON.stringify(real), path + " preloads exactly what it imports " + (JSON.stringify(listed) === JSON.stringify(real) ? "" : JSON.stringify({ listed, real }))); } // A refresh: the frame is there before the data, without the logo, and holds no figures await page.goto(BASE + "/", { waitUntil: "networkidle" }); const kept = await page.evaluate(() => localStorage.getItem("wally.frame")); ok(kept && !/\d[\d.,]*\s*(€|\$)|(€|\$)\s*\d/.test(kept), "the frame is kept, with no money in it"); let release; const held = new Promise((r) => { release = r; }); await page.route("**/api/ledger", async (route) => { await held; await route.continue(); }); await page.reload({ waitUntil: "commit" }); await page.waitForSelector('#side a[href="/transactions"]', { state: "visible" }); ok(await page.locator("#boot").isHidden(), "no logo over the frame"); ok(await page.$eval("#main", (m) => m.children.length === 0), "the frame is up before the data"); // A click on it while the data is on its way ends up on that page await page.click('#side a[href="/transactions"]'); ok(page.url().endsWith("/transactions"), "a click during loading changes the address"); release(); await page.waitForSelector('#main[data-page="transactions"] > *'); await page.unroute("**/api/ledger"); ok(await page.$eval('#side a[href="/transactions"]', (a) => a.classList.contains("on")), "and opens that page"); ok(errors.length === 0, "no errors while opening " + errors.slice(0, 2).join(" | ")); await ctx.close(); } { console.log("Desktop journeys"); const { ctx, page, errors } = await open(1440, 900); await page.goto(BASE + "/", { waitUntil: "networkidle" }); // Every page, then keys on each: nothing a page leaves behind may break another for (const path of ["/transactions", "/recurring", "/settings/people", "/review", "/"]) { await page.click(`#side a[href="${path === "/review" ? "/" : path.replace(/\/people$/, "")}"]`).catch(() => page.goto(BASE + path)); if (path === "/review") await page.goto(BASE + path, { waitUntil: "networkidle" }); await page.waitForTimeout(300); for (const key of ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "1", "2", "Escape"]) await page.keyboard.press(key); } ok(errors.length === 0, "visiting every page and pressing keys leaves no errors " + errors.slice(0, 2).join(" | ")); // Dashboard category → Transactions filtered to it, same range await page.goto(BASE + "/", { waitUntil: "networkidle" }); const catLink = page.locator('[data-w="cats"] [data-go-cat]').first(); if (await catLink.count()) { const name = await catLink.getAttribute("data-go-cat"); await catLink.click(); await page.waitForURL(/\/transactions\?/); ok(page.url().includes("cat=" + encodeURIComponent(name)), "a category on Summary opens Transactions filtered to " + name); const cats = await page.locator("#main .w-row .tx-rm p").allTextContents(); ok(cats.length > 0 && cats.every((c) => c.includes(name)), "every row shown is " + name); } else ok(false, "Summary has category links"); // Budgets are gone: the old address falls back to the Dashboard await page.goto(BASE + "/budgets", { waitUntil: "networkidle" }); ok(await page.locator("#main").getAttribute("data-page") === "dashboard" && await page.locator('#side a[href="/budgets"]').count() === 0, "/budgets opens Summary, and nothing links to it"); // Adding from the Dashboard changes its figures at once await page.goto(BASE + "/", { waitUntil: "networkidle" }); const before = money(await page.locator("#s-big").textContent()); await page.keyboard.press("n"); await page.waitForSelector("#add-modal:not([hidden])"); await page.keyboard.type("100"); await page.keyboard.press("Enter"); await page.keyboard.type("Journey test"); await page.click("#add-cat"); await page.keyboard.press("Enter"); await page.keyboard.press(process.platform === "darwin" ? "Meta+Enter" : "Control+Enter"); await page.waitForSelector("#add-modal", { state: "hidden" }); await page.waitForTimeout(400); const after = money(await page.locator("#s-big").textContent()); // Summary rounds to whole amounts, so a euro either way ok(Math.abs(after - before - 100) <= 1 || Math.abs(after - before - 50) <= 1, `Spent so far moved from ${before} to ${after}`); await page.goto(BASE + "/transactions?q=Journey", { waitUntil: "networkidle" }); ok((await page.locator("#main .w-row").count()) >= 1, "the new expense is found by search"); // Month in review → Summary on that month await page.goto(BASE + "/review", { waitUntil: "networkidle" }); const toDash = page.locator('a[href^="/?month="]').first(); if (await toDash.count()) { const href = await toDash.getAttribute("href"); await toDash.click(); await page.waitForTimeout(500); const ym = href.match(/month=(\d{4})-(\d\d)/); const title = await page.locator("#s-title").textContent(); const name = new Intl.DateTimeFormat("en", { month: "long", timeZone: "UTC" }).format(Date.UTC(+ym[1], +ym[2] - 1, 15)); ok(title.startsWith(name), `Month in review opens Summary on ${ym[1]}-${ym[2]} (${title.trim()})`); } else ok(false, "Month in review links to Summary"); ok(errors.length === 0, "no errors on desktop " + errors.slice(0, 2).join(" | ")); await ctx.close(); } { console.log("Phone journeys"); const { ctx, page, errors } = await open(390, 844); await page.goto(BASE + "/", { waitUntil: "networkidle" }); ok(await page.locator("#tabbar").isVisible(), "the tab bar shows on a phone"); ok(!(await page.locator("#side").isVisible()), "the sidebar is hidden"); ok(await page.locator("#tabbar .w-tabpill a").count() === 4, "four tabs: Summary, Transactions, Recurring, Settings"); for (const href of ["/transactions", "/recurring", "/settings", "/"]) { await page.click(`#tabbar a[href="${href}"]`); await page.waitForTimeout(300); ok((await page.locator(`#tabbar a[href="${href}"]`).getAttribute("aria-current")) === "page", "tab " + href + " is current"); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); ok(overflow <= 0, "no sideways scroll on " + href); } // Without the sidebar, the person menu is where you switch person (or sign out) await page.click(".w-pswitch-b"); ok(await page.locator(".w-pswitch-m [data-switch]").isVisible(), "the person menu offers Switch person"); await page.keyboard.press("Escape"); const add = await page.locator("#tabbar .w-tabadd").boundingBox(); ok(add.x >= 0 && add.x + add.width <= 390 && add.y + add.height <= 844, "the + button is whole on screen"); await page.click("#tabbar .w-tabadd"); ok(await page.locator("#add-modal").isVisible(), "Add in the tab bar opens the sheet"); ok(errors.length === 0, "no errors on a phone " + errors.slice(0, 2).join(" | ")); await ctx.close(); } await browser.close(); console.log(failed ? failed + " failed" : "all passed"); process.exit(failed ? 1 : 0);