import { mock } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { mediaFor, ruleFor, sheet } from "./css.js"; import { forgetControl, headingOf, installPage, inViewOf, primaryOf, screenOf, settle, timedTest, until } from "./boot.js"; import type { StubElement } from "./stub-dom.js"; const mainTest = timedTest(2000); // main.ts runs on import and renders at once, so the page must be installed before it. const page = installPage(); const { app, history, location, storage, fetches, confirms } = page; await import("../src/main.js"); function panels(): StubElement[] { return app.all("section.panel"); } function track(): StubElement { return app.firstChild as StubElement; } const inView = (): StubElement => inViewOf(app); function inertFlags(): boolean[] { return panels().map((p) => p.hasAttribute("inert")); } function rail(): StubElement { return track().all("div.rail")[0]!; } function railStyle(): string { return rail().getAttribute("style") ?? ""; } function menuItem(label: string): StubElement { const found = page.chrome.all("button.menu-item").find((b) => b.textContent === label); assert.ok(found, `the menu has no "${label}"`); return found; } const heading = headingOf; const primary = primaryOf; // ------------------------------------------------------------------ the track mainTest("main: a first load renders the landing screen, with the other three panels inert", () => { assert.equal(panels().length, 4, "the landing screen to the order summary are four panels of one track"); assert.equal(track().getAttribute("class"), "track"); assert.deepEqual(inertFlags(), [false, true, true, true]); assert.equal(heading(inView()), "Support SimpleX"); assert.equal(railStyle().includes("translateX(-0%)"), true, railStyle()); assert.equal(history.url, "/", "the landing screen carries no hash, so browser Back leaves the site"); }); mainTest("main: the four panels are the landing screen, the tier list, the duration list and the order summary in order, built once", () => { assert.deepEqual(panels().map(heading), ["Support SimpleX", "Choose your badge", "How long?", "Check your order"]); }); mainTest("main: prefers-reduced-motion: reduce JUMPS instead of scrolling", () => { // An explicit ScrollOptions.behavior overrides the element's computed scroll-behavior, so main.ts must read the reduced-motion query in JS, not rely on the CSS rule alone. page.reducedMotion(true); try { primary(inView())!.click(); assert.equal(heading(inView()), "Choose your badge"); assert.ok(railStyle().includes("translateX(-100%)"), `it still moves: ${railStyle()}`); assert.ok(railStyle().includes("--slide:0ms"), `but it does not animate: ${railStyle()}`); inView().all("button.back")[0]!.click(); assert.equal(heading(inView()), "Support SimpleX"); assert.ok(railStyle().includes("--slide:0ms"), railStyle()); } finally { page.reducedMotion(false); } }); mainTest("main: Continue scrolls right by one panel and pushes a history entry", () => { primary(inView())!.click(); assert.equal(heading(inView()), "Choose your badge"); assert.deepEqual(inertFlags(), [true, false, true, true]); assert.ok(railStyle().includes("translateX(-100%)"), railStyle()); assert.ok(railStyle().includes(`--slide:320ms`), railStyle()); assert.equal(history.url, "/#/tier", "each wizard step is its own history entry"); assert.equal(history.stack.length, 2); }); mainTest("main: [ Back ] is history.back(), and scrolls the track left again", () => { const back = inView().all("button.back")[0]!; assert.equal(back.textContent, "Back"); assert.equal(back.all("svg.chevron").length, 1, "with the chevron before the word"); back.click(); assert.equal(heading(inView()), "Support SimpleX"); assert.deepEqual(inertFlags(), [false, true, true, true]); assert.ok(railStyle().includes("translateX(-0%)"), `Back travels left: ${railStyle()}`); assert.ok(railStyle().includes("--slide:320ms"), "and it animates, as Continue does"); assert.equal(history.url, "/"); assert.equal(history.at, 0); }); mainTest("main: browser Back on the landing screen leaves the site rather than moving the track", () => { assert.equal(history.at, 0); history.back(); assert.equal(history.left, true); assert.equal(heading(inView()), "Support SimpleX"); history.left = false; }); mainTest("main: the wizard walks the landing screen to the order summary, with the catalog's own figures", () => { primary(inView())!.click(); assert.equal(heading(inView()), "Choose your badge"); const legend = inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!; assert.ok(legend.textContent.includes("$70 / month"), "the tier list's price comes from the compiled-in catalog"); legend.click(); primary(inView())!.click(); assert.equal(heading(inView()), "How long?"); const twelve = inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!; assert.ok(twelve.textContent.includes("$420"), "12 legend months are $420"); assert.ok(twelve.textContent.includes("50% off"), "the saving is the only figure the browser computes"); twelve.click(); primary(inView())!.click(); assert.equal(heading(inView()), "Check your order"); assert.ok(inView().textContent.includes("$420.00")); assert.ok(railStyle().includes("translateX(-300%)"), railStyle()); assert.deepEqual(inertFlags(), [true, true, true, false]); assert.equal(history.url, "/#/checkout"); assert.equal(history.stack.length, 4); }); mainTest("main: the answers are in the store, and never in the URL", () => { const session = JSON.parse(storage.getItem("sb.session.v1")!) as Record; assert.equal(session.priceId, "price_legend"); assert.equal(session.offerId, "offer_12m"); assert.equal(history.url, "/#/checkout", "a URL passed to someone else transfers no selection"); }); mainTest("main: a 429 lands on the rate-limited screen, counts down, and re-enables Pay at zero", async () => { mock.timers.enable({ apis: ["setInterval"] }); try { page.respondWith({ status: 429, body: { error: "rate_limited" }, headers: { "retry-after": "46" } }); const before = fetches.length; inView().all("button.primary")[0]!.click(); await until(() => heading(inView()) === "Too many attempts", "the rate-limited screen"); assert.equal(fetches.length, before + 1); const rateLimited = inView(); assert.equal(heading(rateLimited), "Too many attempts"); assert.ok(rateLimited.textContent.includes("Try again in 46 seconds")); const disabled = rateLimited.all("button.primary")[0]!; assert.ok(disabled.hasAttribute("disabled"), "the Pay button is disabled for exactly Retry-After"); mock.timers.tick(1000); assert.ok(inView().textContent.includes("Try again in 45 seconds"), inView().textContent); mock.timers.tick(44_000); assert.ok(inView().textContent.includes("Try again in 1 second"), inView().textContent); assert.equal(heading(inView()), "Too many attempts", "still the rate-limited screen one second short"); mock.timers.tick(1000); assert.equal(heading(inView()), "Check your order", "at zero the buyer is back on the order summary"); assert.ok(primary(inView()), "with an enabled Pay button"); const rebuilt = inView(); mock.timers.tick(60_000); assert.equal(inView(), rebuilt, "the interval was cleared, not left running"); } finally { mock.timers.reset(); } }); mainTest("main: a second press of Pay creates no second invoice", async () => { page.respondWith({ status: 500, body: { error: "internal" } }); const before = fetches.length; const pay = inView().all("button.primary")[0]!; pay.click(); pay.click(); pay.click(); await until(() => heading(inView()) === "That did not go through", "the failure screen"); assert.equal(fetches.length, before + 1, "exactly one POST"); assert.ok(pay.hasAttribute("disabled"), "the guard is the disabled attribute, as screens.ts uses"); assert.equal(heading(inView()), "That did not go through", "a 500 is the failure screen, in the checkout's place on the track"); assert.equal(panels().length, 4, "the track keeps its four panels"); page.respondWith({ status: 500, body: { error: "internal" } }); inView().all("button.primary")[0]!.click(); await until(() => fetches.length === before + 2, "the retry's POST"); await settle(); assert.equal(heading(inView()), "That did not go through"); inView().all("button.back")[0]!.click(); assert.equal(heading(inView()), "Check your order", "Back on the failure screen is the order summary again, in place"); assert.ok(primary(inView()), "with an enabled Pay button"); page.respondWith({ status: 500, body: { error: "internal" } }); primary(inView())!.click(); await until(() => heading(inView()) === "That did not go through", "the failure screen again"); // Back out of the failure screen so the next test starts on a fresh order summary. history.back(); await settle(); assert.equal(heading(inView()), "How long?"); primary(inView())!.click(); assert.equal(heading(inView()), "Check your order"); }); mainTest("main: the 200 REPLACES the history entry, so Back cannot resubmit", async () => { const before = history.stack.length; page.respondWith({ status: 200, body: { invoiceId: "inv_9f3a", badgeType: "legend", months: 12, amount: 42000, currency: "usd", expiresAt: "2126-08-28T13:00:00Z", address: "48HqK2XmVexampleAddress9fRtWc", cryptoAmount: "1.482", cryptoCurrency: "xmr", }, }); const pay = inView().all("button.primary")[0]!; assert.ok(pay.textContent.startsWith("Pay $420.00 with ")); pay.click(); await until(() => location.search === "?order=inv_9f3a", "the invoice response"); await settle(); assert.equal(history.stack.length, before, "the entry is replaced, not pushed"); assert.equal(history.url, "/?order=inv_9f3a"); assert.equal(fetches[0]!.url, "/api/invoice"); assert.equal(fetches[0]!.init!.method, "POST"); assert.equal(app.all("div.track").length, 0); const screen = screenOf(app); assert.ok(screen.textContent.includes("Send 1.482 XMR"), screen.textContent); assert.ok(screen.textContent.includes("48HqK2XmVexampleAddress9fRtWc")); assert.ok(screen.textContent.includes("inv_9f3a")); const stored = JSON.parse(storage.getItem("sb.orders.v1")!) as Array>; assert.equal(stored.length, 1); assert.equal(stored[0]!.orderId, "inv_9f3a"); assert.ok(stored[0]!.code!.startsWith("SB-")); assert.ok(!screen.textContent.includes(stored[0]!.code!), "the payment screen must never show the code"); assert.ok(!JSON.stringify(fetches[0]!.init!.body).includes(stored[0]!.code!.replace(/-/g, "").replace(/^SB/, "")), "the plaintext code never leaves the browser — only its hash"); assert.equal(storage.getItem("sb.session.v1"), null); }); mainTest("main: the waiting loop holds, and the hidden tab releases it", async () => { const waits = fetches.filter((f) => f.url.includes("?wait=")); assert.equal(waits.length, 1, "exactly one loop, holding once"); assert.equal(waits[0]!.url, "/api/invoice/inv_9f3a?wait=open&seenPaid=&seenFull=0"); const signal = waits[0]!.init!.signal!; assert.equal(signal.aborted, false); page.document.hidden = true; page.document.dispatch("visibilitychange"); await settle(); assert.equal(signal.aborted, true, "a hidden tab holds no connection"); page.document.hidden = false; page.document.dispatch("visibilitychange"); await settle(); const after = fetches.filter((f) => f.url.includes("?wait=")); assert.equal(after.length, 2, "resume reissues exactly one request, not two"); assert.equal(after[1]!.init!.signal!.aborted, false); }); mainTest("main: Back from a payment screen RETURNS TO THE WIZARD", async () => { // Resume applies only to a fresh load, so a history navigation follows the URL even with an empty search and an open order present. assert.ok(storage.getItem("sb.orders.v1")!.includes('"status":"open"'), "an open order exists to be resumed"); const held = fetches.filter((f) => f.url.includes("?wait=")).at(-1)!.init!.signal!; assert.equal(held.aborted, false); history.back(); await settle(); assert.equal(location.search, "", "the ?order= entry is behind us"); assert.equal(heading(inView()), "How long?", "Back lands on the wizard, not back on the payment screen"); assert.equal(app.all("div.track").length, 1, "the track is on screen again"); assert.equal(held.aborted, true, "navigating away must stop the loop"); const before = fetches.length; await settle(); assert.equal(fetches.length, before, "and it must not reissue"); }); mainTest("main: Back after a purchase reaches a usable duration list, not an empty one", () => { // The session was cleared on the 200, so the duration list rebuilds its chosen answer from the order record, not the session. assert.equal(storage.getItem("sb.session.v1"), null, "the session really is gone"); const durations = inView(); assert.equal(heading(durations), "How long?"); const choices = durations.all("button.choice"); assert.equal(choices.length, 3, "the duration list must be priced, not empty"); const twelve = choices.find((c) => c.textContent.startsWith("12 months"))!; assert.equal(twelve.getAttribute("aria-pressed"), "true", "with the duration still chosen"); assert.ok(primary(durations), "and Continue must not be permanently disabled"); history.back(); assert.equal(heading(inView()), "Choose your badge"); const legend = inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!; assert.equal(legend.getAttribute("aria-pressed"), "true"); history.back(); assert.equal(heading(inView()), "Support SimpleX"); assert.equal(history.at, 0, "and the landing screen is the bottom of the stack"); }); mainTest("main: a bare `/` is the landing screen, so browser Back leaves the site", () => { assert.equal(location.hash, ""); assert.equal(heading(inView()), "Support SimpleX"); history.back(); assert.equal(history.left, true, "there is nothing behind the landing screen"); history.left = false; assert.equal(heading(inView()), "Support SimpleX"); }); mainTest("main: the menu opens the history list from the store, with [ Open ] as a link", async () => { assert.equal(heading(inView()), "Support SimpleX"); assert.equal(inView().all("button.link").length, 0, "the landing page carries no navigation of its own"); menuItem("Your codes").click(); await until(() => app.all("li.entry").length > 0, "the history list's list"); assert.equal(location.hash, "#/codes", "pushState must update location synchronously"); const codes = screenOf(app); assert.equal(heading(codes), "Your codes"); const rows = codes.all("li.entry"); assert.equal(rows.length, 1); assert.ok(rows[0]!.textContent.includes("waiting for payment"), rows[0]!.textContent); const open = rows[0]!.all("a.secondary")[0]!; assert.equal(open.textContent, "Open"); assert.equal(open.getAttribute("href"), "?order=inv_9f3a"); // The fallback store is an in-memory Map, so the plain click must be handled in-document or a full navigation would discard every stored code. const plain = open.click(); assert.equal(plain.defaultPrevented, true, "the plain click is handled in-document"); await settle(); assert.equal(location.search, "?order=inv_9f3a"); assert.equal(storage.getItem("sb.orders.v1") !== null, true, "and the store survives"); history.back(); await settle(); assert.equal(location.hash, "#/codes"); const stored = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ code: string }>)[0]!.code; assert.ok(stored.startsWith("SB-")); assert.ok(!codes.serialize().includes(stored), "the history list leaked an unpaid code"); }); mainTest("main: [ Forget everything ] does nothing when the confirmation is refused", () => { const before = storage.getItem("sb.orders.v1"); assert.ok(before !== null && before.includes("inv_9f3a")); confirms.length = 0; page.confirmAnswer(false); forgetControl(page)!.click(); assert.equal(confirms.length, 1, "the one irreversible action must ask first"); assert.equal(storage.getItem("sb.orders.v1"), before, "a refused confirmation keeps every code"); assert.equal(app.all("li.entry").length, 1, "and the list stands"); }); mainTest("main: [ Forget everything ] removes the key and returns to the landing page", () => { page.confirmAnswer(true); forgetControl(page)!.click(); assert.equal(storage.getItem("sb.orders.v1"), null); assert.equal(storage.getItem("sb.session.v1"), null, "the draft goes with the codes"); // Every screen but the landing page draws from data just deleted, so the wipe navigates to the landing page rather than leave a stale code on screen. assert.equal(history.url, "/", "the URL names the landing page, not the wiped order"); const landing = screenOf(app); assert.ok(landing.textContent.includes("Support SimpleX"), landing.textContent); assert.ok(landing.textContent.includes("Choose your badge")); assert.ok(!landing.textContent.includes("Nothing bought on this device")); }); mainTest("main: the provider-unavailable screen notice does not outlive the checkout it belongs to", async () => { assert.equal(heading(inView()), "Support SimpleX"); const walk = (): void => { primary(inView())!.click(); inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))?.click(); primary(inView())!.click(); inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))?.click(); primary(inView())!.click(); }; walk(); assert.equal(heading(inView()), "Check your order"); page.respondWith({ status: 503, body: { error: "provider_unavailable" } }); primary(inView())!.click(); await until(() => inView().textContent.includes("temporarily unavailable"), "the provider-unavailable screen"); assert.ok(inView().textContent.includes("Monero is temporarily unavailable")); assert.ok(inView().textContent.includes("Pay $420.00 with Bitcoin"), "the unknown-order screen re-labels Pay to what is available"); history.back(); history.back(); assert.equal(heading(inView()), "Choose your badge"); inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); primary(inView())!.click(); inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click(); primary(inView())!.click(); assert.equal(heading(inView()), "Check your order"); assert.ok(!inView().textContent.includes("temporarily unavailable"), inView().textContent); assert.equal(inView().all("button.choice").filter((c) => c.hasAttribute("disabled")).length, 0, "and no method is left disabled by an attempt that is no longer being made"); page.respondWith({ status: 503, body: { error: "provider_unavailable" } }); primary(inView())!.click(); await until(() => inView().textContent.includes("temporarily unavailable"), "the provider-unavailable screen again"); history.back(); history.back(); history.back(); assert.equal(heading(inView()), "Support SimpleX"); walk(); assert.equal(heading(inView()), "Check your order"); assert.ok(!inView().textContent.includes("temporarily unavailable"), inView().textContent); }); mainTest("main: nothing is left running when the page has been navigated away from", async () => { // A leaked interval or live loop would keep the process alive past the last test and turn a failure into a two-minute hang. const before = fetches.length; await settle(10); assert.equal(fetches.length, before, "no loop is still issuing requests"); for (const f of fetches.filter((x) => x.url.includes("?wait="))) { assert.equal(f.init!.signal!.aborted, true, "every waiting request has been aborted"); } }); // ------------------------------------------------------------- the stylesheet mainTest("styles: colour tokens are on bare :root, by value, and redefined under dark", () => { const bare = ruleFor(sheet.rules, ":root"); assert.ok(bare, "the palette must be defined on bare :root, not only inside a media query"); for (const token of ["--bg", "--ink", "--ink-soft", "--line", "--accent"]) { const value = bare.decls.get(token); assert.ok(value !== undefined && value.length > 0, `${token} must be defined on bare :root`); } assert.equal(bare.decls.get("--accent")!.toUpperCase(), "#0053D0", "the accent is the website's primary-light"); const dark = mediaFor("(prefers-color-scheme: dark)"); assert.ok(dark, "the dark query must exist"); // The dark rule is guarded so the menu's explicit Light beats an operating system set to dark. const darkRoot = ruleFor(dark.rules, ':root:not([data-theme="light"])'); assert.ok(darkRoot, "and must redefine :root for everything but an explicit light"); for (const token of ["--bg", "--ink", "--ink-soft", "--line"]) { const value = darkRoot.decls.get(token); assert.ok(value !== undefined, `${token} must be redefined under dark`); assert.notEqual(value, bare.decls.get(token), `${token} must actually differ in dark`); } assert.ok(ruleFor(sheet.rules, ':root[data-theme="dark"]'), "the menu's Dark needs a rule of its own, or it does nothing on a light machine"); }); mainTest("styles: reduced motion turns both halves of the slide off", () => { // The slide is a transform on the rail and a height on the track, so either one left animating is still motion. for (const selector of [".track", ".rail"]) { const rule = ruleFor(sheet.rules, selector); assert.ok(rule, `${selector} must exist`); assert.match(rule.decls.get("transition") ?? "", /var\(--slide/, `${selector} must take its duration from the token main.ts writes`); } const reduce = mediaFor("(prefers-reduced-motion: reduce)"); assert.ok(reduce, "the reduced-motion query must exist"); const off = reduce.rules.filter((r) => r.decls.get("transition") === "none").flatMap((r) => r.selector); for (const selector of [".track", ".rail"]) { assert.ok(off.includes(selector), `the query must turn ${selector}'s transition off, not ${JSON.stringify(reduce.rules.map((r) => r.selector))}`); } }); mainTest("styles: the track clips, the rail travels, and each panel is one whole column", () => { // One element cannot both clip and translate, so the track clips and the rail travels. const track = ruleFor(sheet.rules, ".track")!; assert.equal(track.decls.get("overflow"), "hidden"); const rail = ruleFor(sheet.rules, ".rail")!; assert.equal(rail.decls.get("display"), "flex"); assert.equal(rail.decls.get("width"), "100%"); const panel = ruleFor(sheet.rules, ".panel"); assert.ok(panel, ".panel must exist"); assert.equal(panel.decls.get("flex")?.replace(/\s+/g, " "), "0 0 100%", "each panel is exactly the column's width"); assert.equal(ruleFor(sheet.rules, "#app")!.decls.get("max-width"), "640px"); assert.equal(ruleFor(sheet.rules, ".panel[inert]"), undefined, "an inert panel keeps its height, or the two screens are never on stage together"); }); mainTest("styles: below 560 px it is the same track, with no layout redeclared", () => { const narrow = sheet.media.filter((m) => m.query.replace(/\s+/g, "") === "(max-width:560px)"); assert.ok(narrow.length > 0, "the narrow query must exist"); const oneLayout = /^\.(track|rail|panel|choices|choice|split|rows|row|entries|entry)\b/; const layout = /^(display|position|float|width|height|flex|flex-.*|grid|grid-.*|columns|transform|inset|top|left|right|bottom|order)$/; for (const block of narrow) { for (const rule of block.rules) { if (!oneLayout.test(rule.selector)) continue; for (const prop of rule.decls.keys()) { assert.ok(!layout.test(prop), `${rule.selector} redeclares "${prop}" below 560px — that is a second layout`); } } } assert.equal(ruleFor(sheet.rules, ".panel")!.decls.get("flex")?.replace(/\s+/g, " "), "0 0 100%"); }); mainTest("styles: on a phone the menu is a sheet rather than a floating panel", () => { const narrow = sheet.media.filter((m) => m.query.replace(/\s+/g, "") === "(max-width:560px)"); const rule = narrow.flatMap((m) => m.rules).find((r) => r.selector === ".menu"); assert.ok(rule, "the menu must be redrawn below 560px, where 320px does not fit in the column"); assert.equal(rule.decls.get("position"), "fixed"); assert.equal(rule.decls.get("width"), "auto", "it spans the page instead of floating in it"); }); mainTest("shell: index.html carries the prerendered first screen, straight from screens.ts", async () => { const html = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8"); assert.ok(/