Files
simplex-chat/apps/simplex-badge-service/web/test/screens.test.ts
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

1237 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mock } from "node:test";
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
const domTest = timedTest(2000);
import { StubElement, installDocument } from "./stub-dom.js";
import { historyRows, withoutCode } from "../src/order.js";
type UnpaidOrder = import("../src/order.js").UnpaidOrder;
const { clipboard } = installDocument();
const copied = clipboard.writes;
// The stub must be installed before screens.ts is imported, since el reaches for document at call time.
const screens = await import("../src/screens.js");
const { WEFUNDER_URL } = await import("../src/crowdfunding.js");
type Rec = import("../src/domain.js").OrderRecord;
type View = import("../src/api.js").InvoiceView;
function render(node: unknown): StubElement { return node as unknown as StubElement; }
// Checks the whole serialized subtree, since a code hidden in an attribute is invisible to textContent.
function assertNoCode(node: StubElement, where: string): void {
const dump = node.serialize();
for (const form of [HELD_CODE, HELD_CODE.replace(/-/g, ""), HELD_CODE.replace(/^SB-/, ""), "SB-"]) {
assert.ok(!dump.includes(form), `${where} leaked a code (${form}) into: ${dump.slice(0, 400)}`);
}
}
// An order still carrying its code, handed to a screen whose type says it cannot.
function smuggled(over: Partial<Rec> = {}): UnpaidOrder {
return record({ code: HELD_CODE, ...over });
}
function unpaidRecord(over: Partial<Rec> = {}): UnpaidOrder {
return withoutCode(record(over));
}
function record(over: Partial<Rec> = {}): Rec {
return {
orderId: "inv_9f3a", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:46:00Z", status: "open", ...over,
};
}
const openXmr: View = {
status: "open", badgeType: "legend", months: 12, amount: 42000,
currency: "usd", expiresAt: "2026-08-28T12:58:12Z",
address: "48HqK2XmVexampleAddress9fRtWc", cryptoAmount: "1.482", cryptoCurrency: "xmr",
};
const NOW = Date.parse("2026-08-28T12:00:00Z");
const HELD_CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
const noop = (): void => {};
const noopAsync = (): Promise<void> => Promise.resolve();
domTest("screens: the landing screen carries the copy the mockups fix", () => {
const p = render(screens.landing({ onStart: noop }));
const text = p.textContent;
for (const line of [
"Support SimpleX",
"Get a badge to send larger files (2‑5GB) that stay available longer (7‑21 days), and to show it on your profile.",
"Choose your badge",
"Invest $100+ in SimpleX Chat and get a free supporter badge.",
"Learn more on ",
"You pay once for the months you choose. No subscription, no account.",
]) assert.ok(text.includes(line), `the landing screen is missing: ${line}`);
assert.equal(p.all("button.link").length, 0, "the history is the menu's, not the landing page's");
const invest = p.all("div.invest");
assert.equal(invest.length, 1, "one invest panel, under the main button");
const links = invest[0]!.all("a.invest-link");
assert.equal(links.length, 1, "the block's one action is the link to Wefunder, and it is a link, not a second button");
assert.equal(invest[0]!.all("button").length, 0);
assert.equal(links[0]!.getAttribute("href"), WEFUNDER_URL);
assert.equal(links[0]!.getAttribute("target"), "_blank", "the checkout stays open behind it");
assert.equal(links[0]!.all("svg.wefunder").length, 1, "the link carries the Wefunder wordmark");
assert.equal(links[0]!.getAttribute("aria-label"), "Learn more on Wefunder", "and reads as the words the wordmark stands for");
assert.equal(invest[0]!.all("a").length, 1, "the Wefunder link is the block's only link");
const kids = p.children.map((c) => (c as { getAttribute?: (n: string) => string | null }).getAttribute?.("class") ?? "");
assert.ok(kids.indexOf("notes") < kids.indexOf("primary") && kids.indexOf("primary") < kids.indexOf("invest"),
"the notes sit above the button, the invest block under it, and nothing after");
assert.equal(kids[kids.length - 1], "invest", "the invest block is the last thing on the screen");
});
domTest("screens: the tier list carries the level copy, and Continue is disabled until one is chosen", () => {
const tiers = [
{ priceId: "price_supporter", badgeType: "supporter", name: "Supporter", price: "$7 / month", features: ["Files up to 2 GB", "Stored for 7 days"], disabled: false },
{ priceId: "price_legend", badgeType: "legend", name: "Legend", price: "$70 / month", features: ["Files up to 5 GB", "Stored for 21 days"], disabled: false },
];
const blank = render(screens.tiers({ tiers, selected: undefined, onSelect: noop, onContinue: noop, onBack: noop }));
for (const line of ["Choose your badge", "Larger files that stay available longer.",
"Supporter", "$7 / month", "Files up to 2 GB", "Stored for 7 days", "Legend", "$70 / month", "Files up to 5 GB", "Stored for 21 days", "Back",
"Invest $100+ in SimpleX Chat and get a free supporter badge."]) {
assert.ok(blank.textContent.includes(line), `the tier list is missing: ${line}`);
}
const legend = render(screens.tiers({ tiers, selected: "price_legend", perk: { badgeType: "legend", amount: 1000 },
onSelect: noop, onContinue: noop, onBack: noop }));
assert.ok(legend.textContent.includes("Invest $1,000+ in SimpleX Chat and get a free legend badge."),
"with a tier chosen, the panel names that tier's minimum");
assert.ok(blank.all("button.primary")[0]!.hasAttribute("disabled"), "Continue must wait for an answer");
let chosen = "";
const picked = render(screens.tiers({ tiers, selected: "price_legend", onSelect: (id) => { chosen = id; }, onContinue: noop, onBack: noop }));
assert.equal(picked.all("button.primary")[0]!.hasAttribute("disabled"), false);
assert.deepEqual(picked.all("button.choice").map((c) => c.getAttribute("aria-pressed")), ["false", "true"]);
picked.all("button.choice")[0]!.click();
assert.equal(chosen, "price_supporter");
});
domTest("screens: a tier with no total is disabled and cannot be chosen", () => {
let chosen = "";
const p = render(screens.tiers({
tiers: [{ priceId: "price_broken", badgeType: "legend", name: "Legend", price: "", features: [], disabled: true }],
selected: undefined, onSelect: (id) => { chosen = id; }, onContinue: noop, onBack: noop,
}));
const card = p.all("button.choice")[0]!;
assert.ok(card.hasAttribute("disabled"));
card.click();
assert.equal(chosen, "", "a disabled tier has no listener at all");
});
domTest("screens: the duration list prints the durations and their savings, unpriced where there is no total", () => {
const p = render(screens.durations({
durations: [
{ key: "", name: "1 month", price: "$70", disabled: false },
{ key: "offer_3m", name: "3 months", price: "$140", savingPercent: 33, disabled: false },
{ key: "offer_12m", name: "12 months", price: "$420", savingPercent: 50, disabled: false },
{ key: "offer_bad", name: "24 months", disabled: true },
],
selected: "offer_12m", onSelect: noop, onContinue: noop, onBack: noop,
}));
for (const line of ["How long?", "Paid once, for the months you choose. No subscription.",
"1 month", "$70", "3 months", "$140", "33% off", "12 months", "$420", "50% off"]) {
assert.ok(p.textContent.includes(line), `the duration list is missing: ${line}`);
}
assert.equal(p.all("p.lede").length, 1, "with no tier named, the one line is the payment terms");
const priced = render(screens.durations({
durations: [{ key: "offer_12m", name: "12 months", price: "$420", disabled: false }],
selected: "offer_12m", tier: { badge: "Legend:", gives: "5 GB files available for 21 days." },
perk: { badgeType: "legend", amount: 10000, months: 12 }, onSelect: noop, onContinue: noop, onBack: noop,
}));
assert.ok(priced.textContent.includes("Invest $10,000+ in SimpleX Chat and get a free legend badge for 12 months."),
"with a term chosen, the panel names the amount and the term");
const ledes = priced.all("p.lede");
assert.equal(ledes.length, 2, "the chosen badge and what it gives, then the payment terms");
assert.equal(ledes[0]!.textContent, "Legend: 5 GB files available for 21 days.");
assert.equal(ledes[1]!.textContent, "Paid once, for the months you choose. No subscription.");
const bad = p.all("button.choice")[3]!;
assert.ok(bad.hasAttribute("disabled"));
assert.equal(bad.textContent, "24 months", "a duration with no total renders unpriced");
});
domTest("screens: the order summary is the summary and the method row, with the total on the Pay button", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr",
onSelect: noop, onPay: noop, onBack: noop,
}));
for (const line of ["Check your order", "Your badge", "Legend", "Duration", "12 months", "Total", "$420.00",
"Pay with", "Bitcoin", "Monero", "Card", "Pay $420.00 with Monero",
"Back"]) {
assert.ok(p.textContent.includes(line), `the order summary is missing: ${line}`);
}
assert.ok(!p.textContent.includes("Stripe"), "with a coin chosen, the processor note is not shown");
assert.equal(p.all("div.notes.slot").length, 1, "but its slot is, so the line's absence moves nothing");
assert.deepEqual(p.all("button.choice").map((c) => c.textContent), ["Card", "Bitcoin", "Monero"]);
const card = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "card",
onSelect: noop, onPay: noop, onBack: noop,
}));
assert.ok(card.textContent.includes("Card payments are processed by Stripe."), "with card chosen, the note names the processor");
const kids = card.children.map((c) => (c as { getAttribute?: (n: string) => string | null }).getAttribute?.("class") ?? "");
assert.ok(kids.indexOf("notes slot") < kids.indexOf("primary"), "above the Pay button");
assert.ok(p.textContent.includes("Or invest $100+ and get a free supporter badge."),
"without a perk the block opens on the round's minimum");
});
domTest("screens: the order summary offers the investment under Pay as the same block, as the alternative to paying for this badge", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr",
perk: { badgeType: "legend", amount: 10000, months: 12 },
onSelect: noop, onPay: noop, onBack: noop,
}));
const invest = p.all("div.invest");
assert.equal(invest.length, 1);
assert.ok(invest[0]!.textContent.includes("Or invest $10,000+ and get this badge free."),
"the amount for the badge in the summary above, which the sentence does not name again");
assert.equal(invest[0]!.all("a").length, 1, "the Wefunder link is the block's only link");
assert.equal(invest[0]!.all("a.invest-link")[0]!.getAttribute("href"), WEFUNDER_URL);
assert.equal(invest[0]!.all("button").length, 0);
const kids = p.children.map((c) => (c as { getAttribute?: (n: string) => string | null }).getAttribute?.("class") ?? "");
assert.ok(kids.indexOf("notes") < kids.indexOf("primary") && kids.indexOf("primary") < kids.indexOf("invest"),
"the provider note sits above Pay, and the block under it");
});
domTest("screens: a discounted order shows the gross price and the reduction above the total", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr",
discount: { price: "$840.00", off: "$420.00", percent: 50 },
onSelect: noop, onPay: noop, onBack: noop,
}));
for (const line of ["Price", "$840.00", "Launch discount: 50% off", "−$420.00", "Total", "$420.00"]) {
assert.ok(p.textContent.includes(line), `the discounted summary is missing: ${line}`);
}
const discountRow = p.all("div.discount")[0]!;
assert.ok(discountRow.textContent.includes("−$420.00"), "the reduction is on the discount row");
});
domTest("screens: an order at full price shows no Price or Discount row, only the total", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "supporter", months: 1, total: "$7.00", selected: "xmr",
onSelect: noop, onPay: noop, onBack: noop,
}));
assert.ok(!p.textContent.includes("discount"), "no discount row without a saving");
assert.equal(p.all("div.discount").length, 0);
assert.ok(p.textContent.includes("Total"));
});
domTest("screens: the provider-unavailable screen shows the unavailable method disabled rather than omitting it", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "btc", unavailable: "xmr",
onSelect: noop, onPay: noop, onBack: noop,
}));
assert.ok(p.textContent.includes("Monero is temporarily unavailable"));
assert.ok(p.textContent.includes("Try another method, or come back later."));
assert.ok(p.textContent.includes("Pay $420.00 with Bitcoin"), "the Pay button re-labels to the method now selected");
const monero = p.all("button.choice").find((c) => c.textContent.startsWith("Monero"))!;
assert.ok(monero.hasAttribute("disabled"));
assert.ok(monero.textContent.includes("unavailable"));
assert.equal(p.all("button.choice").length, 3, "the method is shown, not dropped");
});
domTest("screens: the order summary links to an order already waiting for payment", () => {
const opened: string[] = [];
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr",
openOrder: { orderId: "inv_9f3a", onOpen: (id) => opened.push(id) },
onSelect: noop, onPay: noop, onBack: noop,
}));
const line = p.all("a.link")[0]!;
assert.equal(line.textContent, "You have an order waiting for payment");
assert.equal(line.getAttribute("href"), "?order=inv_9f3a", "the id is carried by the link, not the text");
assert.ok(!p.textContent.includes("inv_9f3a"), "and never rendered as text");
const plain = line.click();
assert.equal(plain.defaultPrevented, true, "a plain click is handled in-document");
assert.deepEqual(opened, ["inv_9f3a"]);
});
domTest("screens: the order summary withholds Pay while that order's card payment awaits confirmation", () => {
const opened: string[] = [];
const paid: number[] = [];
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "card",
openOrder: { orderId: "inv_9f3a", awaitingCard: true, onOpen: (id) => opened.push(id) },
onSelect: noop, onPay: () => paid.push(1), onBack: noop,
}));
assert.ok(p.textContent.includes(screens.AWAITING_CARD_TITLE), p.textContent);
assert.equal(p.all("button.primary").length, 0, "no Pay button, not even a disabled one");
assert.ok(!p.textContent.includes("Pay $420.00"));
assert.equal(p.all("button.choice").length, 0, "and no method to choose for an order that cannot be started");
assert.equal(paid.length, 0);
const line = p.all("a.link")[0]!;
assert.equal(line.getAttribute("href"), "?order=inv_9f3a");
assert.ok(!p.textContent.includes("inv_9f3a"), "the id is carried by the link, not the text");
assert.ok(p.textContent.includes("When its invoice expires, a new one can be started there."));
line.click();
assert.deepEqual(opened, ["inv_9f3a"]);
const ordinary = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "card",
openOrder: { orderId: "inv_9f3a", onOpen: noop },
onSelect: noop, onPay: () => paid.push(1), onBack: noop,
}));
assert.ok(!ordinary.textContent.includes(screens.AWAITING_CARD_TITLE));
ordinary.all("button.primary")[0]!.click();
assert.deepEqual(paid, [1], "an order merely waiting for payment does not withhold anything");
});
domTest("screens: an order link stays a real link for a modified click", () => {
// The fallback store is an in-memory Map, so a full navigation destroys every record, but a modified click must still open a new tab.
const opened: string[] = [];
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: historyRows([record({ orderId: "inv_x", status: "open" })]),
onOpen: (id) => opened.push(id), onStart: noop,
}));
const link = p.all("a.secondary")[0]!;
for (const modifier of [{ metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
const e = link.click(modifier);
assert.equal(e.defaultPrevented, false, `${JSON.stringify(modifier)} must stay a real navigation`);
}
assert.deepEqual(opened, [], "and must not also route in-document");
assert.equal(link.click().defaultPrevented, true);
assert.deepEqual(opened, ["inv_x"]);
});
domTest("screens: the catalog-changed screen has no Back, because [ Start again ] replaces the entry", () => {
let restarted = false;
const p = render(screens.catalogChanged(() => { restarted = true; }));
for (const line of ["These prices have changed", "Start again with the current prices",
"The badge you chose was repriced while you were deciding.", "Nothing was charged."]) {
assert.ok(p.textContent.includes(line), `the catalog-changed screen is missing: ${line}`);
}
assert.equal(p.all("button.back").length, 0, "there is nothing behind the catalog-changed screen");
p.all("button.primary")[0]!.click();
assert.equal(restarted, true);
});
domTest("screens: the rate-limited screen disables Pay and counts the Retry-After seconds down", () => {
// Timers are mocked so a 46-second interval cannot hold the process open past the test.
mock.timers.enable({ apis: ["setInterval"] });
let expired = false;
const { node, stop } = screens.rateLimited(
{ total: "$420.00", method: "xmr", seconds: 46, onBack: noop },
() => { expired = true; },
);
const p = render(node);
try {
assert.ok(p.textContent.includes("Too many attempts"));
assert.ok(p.textContent.includes("Try again in 46 seconds"));
assert.ok(p.textContent.includes("The Pay button is disabled until then."));
const pay = p.all("button.primary")[0]!;
assert.equal(pay.textContent, "Pay $420.00 with Monero");
assert.ok(pay.hasAttribute("disabled"), "the button is disabled for exactly Retry-After seconds");
assert.equal(expired, false);
} finally {
stop();
mock.timers.reset();
}
});
// The countdown runs on an interval that would hold the process open, so stop it once rendered.
function renderAwaitingPayment(o: Parameters<typeof screens.awaitingPayment>[0]) {
const built = screens.awaitingPayment(o);
built.stop();
return render(built.node);
}
domTest("screens: a discounted duration strikes the price it is a discount from", () => {
const p = render(screens.durations({
durations: [
{ key: "1m", name: "1 month", price: "$70", disabled: false },
{ key: "offer_3m", name: "3 months", price: "$140", wasPrice: "$210", savingPercent: 33, disabled: false },
],
selected: undefined, onSelect: noop, onContinue: noop, onBack: noop,
}));
const cards = p.all("button.choice");
assert.equal(cards[1]!.all("s.was")[0]?.textContent, "$210", "the gross is struck through");
assert.ok(cards[1]!.textContent.includes("$140"), "beside the amount actually charged");
assert.ok(cards[1]!.textContent.includes("33% off"));
assert.equal(cards[0]!.all("s.was").length, 0, "one month is not a discount off itself");
});
domTest("screens: the payment screen carries the address, the held rate and the reference — and never a code", () => {
const p = renderAwaitingPayment({
order: smuggled(), invoice: openXmr, method: "xmr",
nowMs: NOW, resumed: false, onCancel: noopAsync,
});
for (const line of ["Send 1.482 XMR", "$420.00 — this rate is held for 58:12", "Monero address",
"48HqK2XmVexampleAddress9fRtWc", "Waiting for the payment to confirm", "Reference", "inv_9f3a",
"Bookmark this page — the address and the countdown both live on this URL."]) {
assert.ok(p.textContent.includes(line), `the payment screen is missing: ${line}`);
}
assertNoCode(p, "awaitingPayment");
assert.ok(!p.textContent.includes("Buy a new code"), "a live payment screen offers no Buy a new code — that is on the closed screen");
});
domTest("screens: the payment screen's held-rate countdown TICKS, rather than freezing until a reload", () => {
mock.timers.enable({ apis: ["setInterval"] });
try {
let clock = NOW;
const built = screens.awaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: clock,
now: () => clock, resumed: false, onCancel: noopAsync,
});
const p = render(built.node);
const rate = (): string => p.all("p.rate")[0]!.textContent;
assert.equal(rate(), "$420.00 — this rate is held for 58:12");
clock += 1000;
mock.timers.tick(1000);
assert.equal(rate(), "$420.00 — this rate is held for 58:11", "one second later, one second less");
clock += 60_000;
mock.timers.tick(1000);
assert.equal(rate(), "$420.00 — this rate is held for 57:11",
"the figure is re-read from expiresAt, not decremented, so a throttled tab cannot drift");
clock += 60 * 60_000;
mock.timers.tick(1000);
assert.equal(rate(), "Checking with the payment network");
mock.timers.tick(60_000);
assert.equal(rate(), "Checking with the payment network", "the interval was cleared, not left running");
built.stop();
} finally {
mock.timers.reset();
}
});
domTest("screens: the payment screen states what it is doing outside the fields, not as one of them", () => {
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
const details = p.all("div.details")[0]!;
assert.equal(details.all("p.awaiting").length, 0, "the status is not stacked with the fields");
assert.equal(p.all("p.awaiting").length, 1, "it is on the panel, under both columns");
assert.ok(p.all("p.awaiting")[0]!.textContent.includes("Waiting for the payment to confirm"));
assert.ok(details.textContent.includes("48HqK2XmVexampleAddress9fRtWc"));
assert.ok(details.textContent.includes("inv_9f3a"));
assert.ok(!details.textContent.includes("Waiting for the payment"));
});
domTest("screens: the offline line sits beside the status, not among the fields", () => {
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW,
resumed: false, offline: true, onCancel: noopAsync,
});
assert.equal(p.all("div.details")[0]!.all("p.offline").length, 0);
assert.ok(p.textContent.includes(screens.OFFLINE_NOTE), "and it is still said");
});
domTest("screens: the payment screen replaces the countdown at zero rather than expiring on its own clock", () => {
const after = Date.parse("2026-08-28T13:30:00Z");
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: after, resumed: false, onCancel: noopAsync,
});
assert.ok(p.textContent.includes("Checking with the payment network"));
assert.ok(!p.textContent.includes("this rate is held for"));
assert.ok(!p.textContent.includes("expired"), "expiry comes from the server, never from here");
});
domTest("screens: a resumed payment screen says how long ago it started, and offers no Buy a new code", () => {
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: true, onCancel: noopAsync,
});
assert.ok(p.textContent.includes("Started 14 minutes ago."));
assert.equal(p.all("button").filter((b) => b.textContent === "Buy a new code").length, 0,
"buying a fresh code is the menu's job, not this screen's");
});
domTest("screens: the payment screen's Copy button puts the address on the clipboard, never the code", () => {
copied.length = 0;
const p = renderAwaitingPayment({
order: smuggled(), invoice: openXmr, method: "xmr",
nowMs: NOW, resumed: false, onCancel: noopAsync,
});
for (const b of p.all("button.secondary")) b.click();
assert.deepEqual(copied, ["1.482", "48HqK2XmVexampleAddress9fRtWc"]);
});
function cancelButton(p: StubElement) {
return p.all("button").find((b) => b.textContent === screens.CANCEL_INVOICE
|| b.textContent === screens.CANCEL_PENDING)!;
}
domTest("screens: the payment screen's cancel is a quiet danger link, not a second block button", () => {
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: true, onCancel: noopAsync,
});
const cancel = cancelButton(p);
const classes = cancel.getAttribute("class")!.split(" ");
assert.ok(classes.includes("link"), cancel.getAttribute("class") ?? "");
assert.ok(classes.includes("danger"), cancel.getAttribute("class") ?? "");
const blocks = p.all("button.secondary")
.filter((b) => !b.getAttribute("class")!.split(" ").includes("inline"));
assert.deepEqual(blocks.map((b) => b.textContent), []);
assert.ok(screens.CANCEL_CONFIRM.includes("stops accepting payment"), screens.CANCEL_CONFIRM);
});
domTest("screens: a cancel in flight cannot be sent twice, and says so when it fails", async () => {
let calls = 0;
let reject: (e: Error) => void = () => {};
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false,
onCancel: () => {
calls += 1;
return new Promise<void>((_, rj) => { reject = rj; });
},
});
const cancel = cancelButton(p);
cancel.click();
assert.equal(calls, 1);
assert.equal(cancel.textContent, screens.CANCEL_PENDING);
assert.ok(cancel.hasAttribute("disabled"));
cancel.click();
assert.equal(calls, 1);
reject(new Error("provider down"));
await new Promise((r) => setImmediate(r));
assert.equal(cancel.textContent, screens.CANCEL_INVOICE);
assert.equal(cancel.hasAttribute("disabled"), false, "a failed cancel can be retried");
assert.ok(p.textContent.includes(screens.CANCEL_FAILED), p.textContent);
});
domTest("screens: the payment screen renders no development note, whatever the origin", () => {
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
assert.equal(p.all("div.warn").length, 0);
assert.equal(p.all("div.command").length, 0);
for (const leak of ["Development stand-in", "control/settle", "curl", "BTCPay", "mock"]) {
assert.ok(!p.textContent.includes(leak), `the payment screen leaked "${leak}"`);
}
});
domTest("screens: the payment screen offers the amount and a wallet link, both carrying the amount", () => {
copied.length = 0;
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
const uri = "monero:48HqK2XmVexampleAddress9fRtWc?tx_amount=1.482";
const link = p.all("a.wallet-link")[0];
assert.ok(link !== undefined, "the payment screen has no wallet link");
assert.equal(link.getAttribute("href"), uri);
// .copy-line is absolutely positioned inside .field.copyable; on its own it anchors to the page under the menu button.
const line = p.all("p.wallet-line")[0];
assert.ok(line !== undefined, "the wallet link needs its own in-flow line");
assert.equal(p.all("p.copy-line").filter((n) => n.all("a.wallet-link").length > 0).length, 0);
assert.ok(p.all("div.qr-wrap")[0]!.all("p.wallet-line").length === 1,
"the link belongs under the symbol it opens");
assert.ok(p.textContent.includes("Amount in XMR"), p.textContent);
for (const b of p.all("button.secondary")) b.click();
assert.deepEqual(copied, ["1.482", "48HqK2XmVexampleAddress9fRtWc"]);
});
domTest("screens: the confirming screen waits, and promises not to expire while it does", () => {
const p = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: smuggled(), gaveUp: false, onCheckAgain: noop }));
for (const line of ["Payment received", "Waiting for the card network to confirm.", "Still processing",
"This usually takes a few seconds. The page updates itself.",
screens.KEEPS_WAITING]) {
assert.ok(p.textContent.includes(line), `the confirming screen is missing: ${line}`);
}
assert.ok(!p.textContent.includes("we stop waiting"), p.textContent);
assertNoCode(p, "awaitingConfirmation");
});
domTest("screens: an underpaid payment screen keeps the address and says what is still owed", () => {
const part = { ...openXmr, amountPaid: 21000, cryptoAmountPaid: "0.741" };
const p = renderAwaitingPayment({
order: unpaidRecord(), invoice: part, method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
assert.ok(p.textContent.includes(screens.PART_PAID_TITLE), p.textContent);
assert.ok(p.textContent.includes("We have seen 0.741 XMR of 1.482 XMR."), p.textContent);
assert.ok(p.textContent.includes("48HqK2XmVexampleAddress9fRtWc"), p.textContent);
assert.equal(p.all("div.warn").length, 1);
});
domTest("screens: the confirming screen echoes what arrived and what settlement needs", () => {
const paid = { ...openXmr, amountPaid: 42000, cryptoAmountPaid: "1.482", requiredConfirmations: 2 };
const p = render(screens.awaitingConfirmation({
order: smuggled(), invoice: paid, method: "xmr", gaveUp: false, onCheckAgain: noop,
}));
assert.ok(p.textContent.includes("Received"), p.textContent);
assert.ok(p.textContent.includes("1.482 XMR"), p.textContent);
assert.ok(p.textContent.includes("This is settled once the payment has 2 confirmations on the Monero blockchain."), p.textContent);
const one = render(screens.awaitingConfirmation({
order: smuggled(), invoice: { ...paid, requiredConfirmations: 1 }, method: "btc", gaveUp: false, onCheckAgain: noop,
}));
assert.ok(one.textContent.includes("has 1 confirmation on the Bitcoin blockchain"), one.textContent);
const zero = render(screens.awaitingConfirmation({
order: smuggled(), invoice: { ...paid, requiredConfirmations: 0 }, method: "btc", gaveUp: false, onCheckAgain: noop,
}));
assert.ok(!zero.textContent.includes("settled once"), zero.textContent);
});
domTest("screens: the confirming screen names the network it is waiting on, and shows the pulse", () => {
for (const [method, confirming, wait] of [
["btc", "Waiting for the Bitcoin network to confirm.", "usually about ten minutes"],
["xmr", "Waiting for the Monero network to confirm.", "usually a couple of minutes"],
] as const) {
const p = render(screens.awaitingConfirmation({ order: smuggled(), invoice: undefined, method, gaveUp: false, onCheckAgain: noop }));
for (const line of ["Payment received", confirming, "Still processing", wait]) {
assert.ok(p.textContent.includes(line), `the confirming screen/${method} is missing: ${line}`);
}
assert.ok(!p.textContent.includes("card network"), p.textContent);
assert.equal(p.all("span.pulse").length, 1, "the processing state has no pulse");
for (const leak of ["Copy", "Open in wallet", "rate is held"]) {
assert.ok(!p.textContent.includes(leak), `the confirming screen/${method} leaked "${leak}"`);
}
assert.ok(p.textContent.includes("inv_9f3a"), "the reference is the only thing to quote");
assertNoCode(p, `the confirming screen/${method}`);
}
});
domTest("screens: the confirming screen's give-up screen offers [ Check again ] and nothing that charges again", () => {
let checked = false;
const p = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined,
order: smuggled(), gaveUp: true, onCheckAgain: () => { checked = true; },
}));
assert.equal(p.all("button").filter((b) => b.textContent === "Buy a new code").length, 0,
"the give-up screen must not offer a control that starts a second charge");
for (const line of ["This is taking longer than expected",
"The payment has not been confirmed. This page keeps working: come back to it later, or quote the reference below.",
"Reference", "inv_9f3a", "Check again"]) {
assert.ok(p.textContent.includes(line), `the confirming screen (gave up) is missing: ${line}`);
}
assertNoCode(p, "the confirming screen (gave up)");
p.all("button.primary")[0]!.click();
assert.equal(checked, true);
});
domTest("screens: the closed-window screen's part-paid variant reads cryptoAmountPaid, and shows no code or order URL", () => {
const p = render(screens.windowClosed({ onNewInvoice: noop,
order: smuggled({ status: "expired" }),
invoice: { ...openXmr, status: "expired", amountPaid: 21000, cryptoAmountPaid: "0.734" },
}));
for (const line of ["This invoice expired", "0.734 XMR arrived, which is not the full amount",
"The rate window has closed, so the shortfall is no longer meaningful.",
"Quote the reference below and we will sort it out.", "inv_9f3a"]) {
assert.ok(p.textContent.includes(line), `the closed-window screen is missing: ${line}`);
}
assertNoCode(p, "windowClosed");
assert.ok(!p.textContent.includes("?order="), "and never the ?order= URL");
});
domTest("screens: a payment that arrived late and in full is not called short", () => {
const p = render(screens.windowClosed({ onNewInvoice: noop,
order: smuggled({ status: "expired" }),
invoice: { ...openXmr, status: "expired", amountPaid: 42000, cryptoAmountPaid: "1.482" },
}));
assert.ok(!p.textContent.includes("not the full amount"), p.textContent);
assert.ok(p.textContent.includes("1.482 XMR arrived after the window closed"), p.textContent);
assert.ok(p.textContent.includes("inv_9f3a"), "and the reference support works from");
});
domTest("screens: the provider's verdict decides the expired screen, not the figures", () => {
const tolerated = render(screens.windowClosed({ onNewInvoice: noop,
order: smuggled({ status: "expired" }),
invoice: { ...openXmr, status: "expired", amountPaid: 41900, cryptoAmountPaid: "1.4785", paidInFull: true },
}));
assert.ok(!tolerated.textContent.includes("not the full amount"), tolerated.textContent);
assert.ok(tolerated.textContent.includes("1.4785 XMR arrived after the window closed"), tolerated.textContent);
const verdictOnly = render(screens.windowClosed({ onNewInvoice: noop,
order: smuggled({ status: "expired" }),
invoice: { ...openXmr, status: "expired", paidInFull: true },
}));
assert.ok(!verdictOnly.textContent.includes("Nothing was received"), verdictOnly.textContent);
assert.ok(verdictOnly.textContent.includes("inv_9f3a"), "and the reference support works from");
});
domTest("screens: a payment worth less than a minor unit is still a payment", () => {
const dust = render(screens.windowClosed({ onNewInvoice: noop,
order: smuggled({ status: "expired" }),
invoice: { ...openXmr, status: "expired", amountPaid: 0, cryptoAmountPaid: "0.00000001", paidInFull: false },
}));
assert.ok(!dust.textContent.includes("Nothing was received"), dust.textContent);
assert.ok(dust.textContent.includes("0.00000001 XMR arrived, which is not the full amount"), dust.textContent);
assert.ok(dust.textContent.includes("inv_9f3a"), "with the reference, which is the actionable part");
});
domTest("screens: the closed-window screen prints no fiat figure it cannot stand behind", () => {
const p = render(screens.windowClosed({ onNewInvoice: noop,
order: unpaidRecord({ status: "expired" }),
invoice: { status: "expired", amountPaid: 30000 },
}));
assert.ok(p.textContent.includes("A payment arrived, which is not the full amount"), p.textContent);
assert.ok(p.textContent.includes("inv_9f3a"), "with the reference, which is the actionable part");
assert.ok(!p.textContent.includes("$0.00"), "and never a total the response did not carry");
assert.ok(!p.textContent.includes("$"), "nor a currency symbol nothing named");
const eur = render(screens.windowClosed({ onNewInvoice: noop,
order: unpaidRecord({ status: "expired" }),
invoice: { status: "expired", amount: 42000, amountPaid: 30000, currency: "eur" },
}));
assert.ok(!eur.textContent.includes("$"), eur.textContent);
});
domTest("screens: the closed-window screen's nothing-received variant is the plain one", () => {
const p = render(screens.windowClosed({ onNewInvoice: noop,
order: unpaidRecord({ status: "expired" }),
invoice: { status: "expired", amount: 42000, currency: "usd" },
}));
assert.ok(p.textContent.includes("Nothing was received, and nothing was charged."));
assert.ok(!p.textContent.includes("arrived, which is not the full amount"));
});
domTest("screens: the code screen is the one screen that prints a code, with the warning", () => {
copied.length = 0;
const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true }));
for (const line of ["Paid. Here is your code.", HELD_CODE, "Copy code", "Redeem it in the app",
"Settings → Supporter perks → Redeem code", "This is the only copy.",
"Saved in this browser and nowhere else.",
"Anyone using this browser can read it, and clearing the browser loses it."]) {
assert.ok(p.textContent.includes(line), `the code screen is missing: ${line}`);
}
p.all("button.primary")[0]!.click();
assert.deepEqual(copied, [HELD_CODE]);
});
domTest("screens: the code screen drops its saved-copy clause where the local write failed", () => {
const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: false }));
assert.ok(p.textContent.includes("This code could not be saved in this browser."));
assert.ok(p.textContent.includes("Copy it now. It is shown here and nowhere else."));
assert.ok(!p.textContent.includes("Saved in this browser"), "the page must not claim a saved copy");
assert.ok(p.textContent.includes(HELD_CODE), "the code is still shown — it exists nowhere else");
});
domTest("screens: the paid-no-code screen names the badge and the reference, and has nothing else to offer", () => {
const p = render(screens.paidNoCode({ order: unpaidRecord({ status: "paid" }), settledAt: "2026-08-24T09:00:00Z" }));
for (const line of ["This code is not on this device",
"The code was generated in the browser it was bought in, and is not stored anywhere else.",
"Quote the reference below and we will sort it out.", "Legend, 12 months", "inv_9f3a"]) {
assert.ok(p.textContent.includes(line), `the paid-no-code screen is missing: ${line}`);
}
assert.ok(p.textContent.includes("paid 24 August"));
});
domTest("screens: a history row states its status beside the title, not on a line of its own", () => {
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: historyRows([
record({ orderId: "a", status: "paid", code: HELD_CODE }),
record({ orderId: "b", status: "open" }),
record({ orderId: "c", status: "expired" }),
]),
onOpen: noop, onStart: noop,
}));
const rows = p.all("li.entry");
assert.ok(rows.length >= 2, "at least a paid row and an unpaid one");
assert.equal(p.all("div.entry-foot").length, 0, "no row keeps a footer for the status alone");
for (const row of rows) {
const lines = row.all("div.entry-row");
const status = row.all("span.status")[0];
assert.ok(status, "every row states where it stands");
assert.ok(lines[0]!.all("span.status").length === 1, "the status is on the title line");
const open = row.all("a.secondary").find((a) => a.textContent === "Open");
if (open !== undefined) {
assert.equal(lines[1]?.all("a.secondary").length, 1, "[ Open ] rides the meta line");
}
}
});
domTest("screens: the history list prints a code only on a paid entry that holds one", () => {
const entries: Rec[] = [
record({ orderId: "a", status: "paid", code: HELD_CODE }),
record({ orderId: "b", status: "open", code: "SB-OPEN0-OPEN0-OPEN0-OPEN0" }),
record({ orderId: "c", status: "expired", code: "SB-EXPD0-EXPD0-EXPD0-EXPD0" }),
record({ orderId: "d", status: "paid" }),
];
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows: historyRows(entries), onOpen: noop, onStart: noop }));
const rows = p.all("li.entry");
assert.equal(rows.length, 4);
assert.ok(rows[0]!.textContent.includes(HELD_CODE));
assert.ok(rows[1]!.textContent.includes("waiting for payment"));
assert.ok(rows[2]!.textContent.includes("this invoice expired"));
assert.ok(rows[3]!.textContent.includes("paid, and the code was not saved here"));
for (const [i, row] of rows.entries()) {
if (i === 0) continue;
assert.ok(!row.serialize().includes("SB-"), `row ${i} leaked a code: ${row.serialize()}`);
}
for (const [i, row] of rows.entries()) {
if (i === 0) continue;
const link = row.all("a.secondary")[0]!;
assert.equal(link.textContent, "Open", "every non-paid line keeps its link");
assert.equal(link.getAttribute("href"), `?order=${entries[i]!.orderId}`,
"[ Open ] is the only invoice id rendered, and it is a link");
}
const forgetLine = p.all("p.forget-line")[0];
assert.ok(forgetLine !== undefined, "the list carries the wipe control at its foot");
const forget = forgetLine.all("button.danger")[0];
assert.ok(forget !== undefined && forget.textContent === "Forget everything on this device",
"and it is the wipe control");
assert.equal(p.all("ul.entries")[0]!.all("button.danger").length, 0, "it is below the list, not a row's control");
});
domTest("screens: the empty codes list offers no wipe control, since there is nothing to wipe", () => {
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows: [], onOpen: noop, onStart: noop }));
assert.ok(!p.textContent.includes("Forget everything on this device"), "nothing bought, nothing to forget");
});
domTest("screens: the wipe control at the foot of the codes list calls back", () => {
let wiped = 0;
const p = render(screens.purchaseHistory({
onForget: () => { wiped += 1; }, keepsNewCodes: true,
rows: historyRows([record({ orderId: "a", status: "paid", code: HELD_CODE })]), onOpen: noop, onStart: noop,
}));
p.all("button.danger")[0]!.click();
assert.equal(wiped, 1);
});
domTest("screens: a history list row is a receipt — badge, level, price, method and time", () => {
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: historyRows([record({
orderId: "a", status: "paid", code: HELD_CODE,
amount: 42000, currency: "usd", method: "xmr",
})]),
onOpen: noop, onStart: noop,
}));
const row = p.all("li.entry")[0]!;
const art = row.all("svg.badge-art");
assert.equal(art.length, 1, "the row draws the badge it is a receipt for");
assert.equal(art[0]!.getAttribute("viewBox"), "8.25 8.25 300 399");
assert.equal(art[0]!.getAttribute("aria-hidden"), "true");
const mark = row.all("svg.mark");
assert.equal(mark.length, 1);
assert.equal(mark[0]!.all("path")[0]!.getAttribute("fill"), "#F60", "Monero's orange");
for (const line of ["Legend, 12 months", "Monero", "$420.00", "28 August 2026, 11:46", "paid"]) {
assert.ok(row.textContent.includes(line), `the row is missing: ${line}`);
}
assert.equal(row.all("div.meta")[0]!.texts.join("|"), "Monero|$420.00|28 August 2026, 11:46");
});
domTest("screens: the four the history list states are told apart by their own ground", () => {
const rows = historyRows([
record({ orderId: "a", status: "paid", code: HELD_CODE }),
record({ orderId: "b", status: "paid" }),
record({ orderId: "c", status: "open" }),
record({ orderId: "d", status: "expired" }),
]);
const drawn = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows, onOpen: noop, onStart: noop })).all("li.entry");
const status = (i: number): StubElement => drawn[i]!.all("span.status")[0]!;
assert.deepEqual(drawn.map((_, i) => status(i).textContent),
["paid", "paid, and the code was not saved here", "waiting for payment", "this invoice expired"]);
assert.deepEqual(drawn.map((_, i) => status(i).getAttribute("class")),
["status settled", "status lost", "status pending", "status lost"]);
});
domTest("screens: a row missing the method or the price shows what it has", () => {
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: historyRows([
record({ orderId: "old", status: "paid", code: HELD_CODE }),
{ orderId: "bare", badgeType: "", months: 0, createdAt: "not a date", status: "open" },
]),
onOpen: noop, onStart: noop,
}));
const rows = p.all("li.entry");
assert.equal(rows.length, 2, "neither entry may be dropped");
const old = rows[0]!;
assert.equal(old.all("svg.mark").length, 0, "no method, so no mark and no name for one");
assert.equal(old.textContent.includes("undefined"), false, old.textContent);
for (const line of ["Legend, 12 months", "28 August 2026", HELD_CODE]) {
assert.ok(old.textContent.includes(line), `the row lost: ${line}`);
}
const bare = rows[1]!;
assert.equal(bare.all("svg").length, 0);
assert.equal(bare.all("div.meta").length, 0, "an empty meta line would draw an empty row of nothing");
assert.equal(bare.all("div.name").length, 0);
assert.equal(bare.textContent.includes("NaN") || bare.textContent.includes("Invalid"), false, bare.textContent);
assert.ok(bare.textContent.includes("waiting for payment"));
assert.equal(bare.all("a.secondary")[0]!.getAttribute("href"), "?order=bare");
});
domTest("screens: the history list's Copy is offered only on a paid entry, and copies that entry's code", () => {
copied.length = 0;
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: historyRows([record({ orderId: "a", status: "paid", code: HELD_CODE }), record({ orderId: "b", status: "open", code: "SB-OPEN0" })]),
onOpen: noop, onStart: noop,
}));
const copies = p.all("li.entry").flatMap((r) => r.all("button.secondary").filter((b) => b.textContent === "Copy"));
assert.equal(copies.length, 1);
copies[0]!.click();
assert.deepEqual(copied, [HELD_CODE]);
});
domTest("screens: an empty store reads 'Nothing bought on this device', with the picture and the invest block", () => {
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows: [], onOpen: noop, onStart: noop }));
assert.ok(p.textContent.includes("Nothing bought on this device"));
assert.ok(p.textContent.includes("Choose your badge"));
assert.equal(p.all("div.hero").length, 1, "the landing's picture, so the screen is not a heading over a button");
assert.equal(p.all("div.invest").length, 1, "and the crowdfunding under the button, as on every screen with one");
});
domTest("screens: the codes list carries the invest block under its wipe control", () => {
const p = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: historyRows([record({ orderId: "a", status: "paid", code: HELD_CODE })]), onOpen: noop, onStart: noop }));
assert.equal(p.all("div.invest").length, 1);
});
domTest("screens: the invoice failure says nothing was charged, and offers [ Try again ] in the checkout's own shape", () => {
let retried = false;
let back = false;
const p = render(screens.invoiceFailure({
perk: { badgeType: "legend", amount: 2500, months: 3 }, onBack: () => { back = true; }, onRetry: () => { retried = true; },
}));
for (const line of ["Back", "That did not go through", "The order was not created, and nothing was charged.",
"If this happens again, get in touch.", "Try again", "Or invest $2,500+ and get this badge free."]) {
assert.ok(p.textContent.includes(line), `the failure screen is missing: ${line}`);
}
assert.equal(p.all("h1.tight").length, 0, "the heading is the wizard's size, not the payment screens' step down");
const kids = p.children.map((c) => (c as { getAttribute?: (n: string) => string | null }).getAttribute?.("class") ?? "");
assert.ok(kids.indexOf("primary") < kids.indexOf("invest"), "the invest block sits under the button, as on the checkout");
p.all("button.primary")[0]!.click();
assert.equal(retried, true);
p.all("button.back")[0]!.click();
assert.equal(back, true);
});
domTest("screens: the unknown order distinguishes nothing, as the unknown-order screen requires", () => {
const p = render(screens.unknownOrder(noop));
assert.ok(p.textContent.includes("This link does not work"));
assert.ok(p.textContent.includes("Check the address you were given, or start again."));
assert.ok(!p.textContent.includes("inv_"), "nothing distinguishes an unknown id from a guess");
});
domTest("screens: an order with no payment details gives the reference AND a way out", () => {
let checked = false;
let fresh = false;
const p = render(screens.detailsUnavailable({
order: smuggled(),
onCheckAgain: () => { checked = true; }, onNewInvoice: () => { fresh = true; },
}));
assert.ok(p.textContent.includes("The payment details are not available"));
assert.ok(p.textContent.includes("inv_9f3a"));
assertNoCode(p, "detailsUnavailable");
p.all("button.primary")[0]!.click();
assert.equal(checked, true);
const out = p.all("button.secondary").find((b) => b.textContent === "Buy a new code");
assert.ok(out, "detailsUnavailable must offer [ Buy a new code ]");
out.click();
assert.equal(fresh, true);
});
domTest("screens: the mounted card form shows the summary and the reference, and no code", () => {
const p = render(screens.cardForm({
order: smuggled(),
invoice: { status: "open", amount: 42000, currency: "usd", clientSecret: "cs_test_abc" },
resumed: false,
}));
assert.ok(p.textContent.includes("Legend"));
assert.ok(p.textContent.includes("$420.00"));
assert.ok(p.textContent.includes("inv_9f3a"));
assert.equal(p.all("div.card-mount").length, 1, "the Payment Element mounts here");
assertNoCode(p, "cardForm");
assert.ok(!p.textContent.includes("cs_test_abc"), "the client secret is never rendered as text");
});
domTest("screens: the card form offers Cancel when a canceler is given, the same control crypto has", () => {
const inv: View = { status: "open", amount: 42000, currency: "usd", clientSecret: "cs_test_abc" };
const hasCancel = (o: Parameters<typeof screens.cardForm>[0]): boolean =>
render(screens.cardForm(o)).all("button").some((b) => b.textContent === screens.CANCEL_INVOICE);
assert.ok(hasCancel({ order: smuggled(), invoice: inv, resumed: false, onCancel: noopAsync }),
"with a canceler the shared cancel control is on the card form");
assert.ok(!hasCancel({ order: smuggled(), invoice: inv, resumed: false }),
"and without one there is no cancel button");
});
domTest("screens: a canceled order reads as canceled in Your codes, over the expired the server left", () => {
const p = render(screens.purchaseHistory({
onForget: noop, keepsNewCodes: true, onOpen: noop, onStart: noop,
rows: historyRows([record({ status: "expired", canceled: true }), record({ orderId: "inv_gone", status: "expired" })]),
}));
const statuses = p.all("span.status").map((s) => s.textContent);
assert.ok(statuses.includes("canceled"), `the canceled row says canceled: ${JSON.stringify(statuses)}`);
assert.ok(statuses.includes("this invoice expired"), "a plain expired invoice still reads as expired");
const rows = p.all("li.entry");
const openOf = (li: StubElement): boolean => li.all("a").some((a) => a.textContent === "Open");
assert.equal(openOf(rows[0]!), false, "the canceled row has no Open button");
assert.equal(openOf(rows[1]!), true, "the plain expired row still has Open");
});
domTest("screens: a closed invoice offers Buy a new code, whether canceled or expired", () => {
const buyNewCode = (o: StubElement): boolean => o.all("button").some((b) => b.textContent === "Buy a new code");
let started = 0;
const canceled = render(screens.windowClosed({ onNewInvoice: () => { started += 1; }, order: smuggled({ status: "expired" }), invoice: undefined, canceled: true }));
assert.ok(canceled.textContent.includes("Invoice canceled"), canceled.textContent);
assert.ok(buyNewCode(canceled), "a canceled invoice offers Buy a new code");
const expired = render(screens.windowClosed({ onNewInvoice: () => { started += 1; }, order: smuggled({ status: "expired" }), invoice: undefined }));
assert.ok(expired.textContent.includes("This invoice expired"), expired.textContent);
assert.ok(buyNewCode(expired), "an expired invoice offers Buy a new code");
expired.all("button").find((b) => b.textContent === "Buy a new code")!.click();
assert.equal(started, 1, "and pressing it starts a fresh purchase");
});
domTest("screens: ACROSS EVERY UNPAID SCREEN, the code is absent from the WHOLE subtree", () => {
const held = smuggled();
const expired = smuggled({ status: "expired" });
const unpaid: Array<[string, StubElement]> = [
["awaitingPayment", renderAwaitingPayment({ order: held, invoice: openXmr, method: "xmr", nowMs: NOW, resumed: true, onCancel: noopAsync })],
["awaitingConfirmation", render(screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: held, gaveUp: false, onCheckAgain: noop }))],
["the confirming screen/gaveUp", render(screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: held, gaveUp: true, onCheckAgain: noop }))],
["the closed-window screen/part", render(screens.windowClosed({ onNewInvoice: noop, order: expired, invoice: { ...openXmr, status: "expired", amountPaid: 21000, cryptoAmountPaid: "0.734" } }))],
["the closed-window screen/none", render(screens.windowClosed({ onNewInvoice: noop, order: expired, invoice: { status: "expired" } }))],
["cardForm", render(screens.cardForm({ order: held, invoice: { status: "open", clientSecret: "cs" }, resumed: true }))],
["detailsUnavailable", render(screens.detailsUnavailable({ order: held, onCheckAgain: noop, onNewInvoice: noop }))],
["the history list", render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows: historyRows([record({ code: HELD_CODE }), record({ status: "expired", code: HELD_CODE })]), onOpen: noop, onStart: noop }))],
];
for (const [name, node] of unpaid) assertNoCode(node, name);
copied.length = 0;
for (const [, node] of unpaid) for (const b of node.all("button.secondary")) b.click();
for (const [, node] of unpaid) for (const b of node.all("button.primary")) b.click();
assert.ok(!copied.some((v) => v.includes("SB-")), `an unpaid code reached the clipboard: ${copied.join(", ")}`);
});
domTest("screens: the code screen is the only screen whose subtree may contain the code", () => {
const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true }));
assert.ok(p.serialize().includes(HELD_CODE), "the code screen must show it — it exists nowhere else");
});
domTest("screens: the code screen says so when the clipboard refuses — the code is the only copy", async () => {
copied.length = 0;
clipboard.fail = true;
try {
const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true }));
const copy = p.all("button.primary")[0]!;
assert.equal(copy.textContent, "Copy code");
copy.click();
await new Promise((r) => setImmediate(r));
assert.deepEqual(copied, [], "nothing was copied");
const status = p.all("p.muted").find((n) => n.getAttribute("role") === "status")!;
assert.equal(status.textContent, "Could not copy. Select it above and copy it by hand.",
"a silent failure would leave the buyer believing they had a copy");
} finally {
clipboard.fail = false;
}
});
domTest("screens: the code screen says so when there is no clipboard at all (an insecure origin)", () => {
clipboard.absent = true;
try {
const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true }));
p.all("button.primary")[0]!.click();
const status = p.all("p.muted").find((n) => n.getAttribute("role") === "status")!;
assert.equal(status.textContent, "Could not copy. Select it above and copy it by hand.");
} finally {
clipboard.absent = false;
}
});
domTest("screens: a successful copy confirms IN PLACE and adds no line, on the code screen and on the payment screen", async () => {
copied.length = 0;
const six = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true }));
const codeButton = six.all("button.primary")[0]!;
const before = six.serialize().length;
codeButton.click();
await new Promise((r) => setImmediate(r));
assert.deepEqual(copied, [HELD_CODE]);
assert.equal(codeButton.textContent, "Copied");
assert.ok(codeButton.getAttribute("class")!.split(" ").includes("copied"), codeButton.getAttribute("class") ?? "");
assert.equal(six.all("p.muted").find((n) => n.getAttribute("role") === "status")!.textContent, "",
"the status line stays empty: it is for a FAILURE, which is not transient");
assert.ok(six.serialize().length < before + "Copied.".length * 2,
"nothing was added to the tree — the label was swapped");
const five = renderAwaitingPayment({
order: smuggled(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
const addressButton = five.all("button.secondary")[1]!;
addressButton.click();
await new Promise((r) => setImmediate(r));
assert.deepEqual(copied, [HELD_CODE, openXmr.address]);
assert.equal(addressButton.textContent, "Copied");
});
domTest("screens: no confirming screen offers [ Buy a new code ] — waiting or given up", () => {
for (const gaveUp of [false, true]) {
const p = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: smuggled(), gaveUp, onCheckAgain: noop }));
assert.equal(p.all("button").filter((b) => b.textContent === "Buy a new code").length, 0,
`the confirming screen (gaveUp: ${gaveUp}) must offer nothing that starts a second charge`);
}
const waiting = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: smuggled(), gaveUp: false, onCheckAgain: noop }));
assert.ok(waiting.textContent.includes("The page updates itself."));
const stopped = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined, order: smuggled(), gaveUp: true, onCheckAgain: noop }));
assert.ok(stopped.textContent.includes("inv_9f3a"), "the reference is the way out instead");
assert.ok(stopped.all("button.primary").some((b) => b.textContent === "Check again"));
});
domTest("screens: one second reads as one second on the first paint, not only on the tick", () => {
mock.timers.enable({ apis: ["setInterval"] });
const { node, stop } = screens.rateLimited({ total: "$420.00", method: "xmr", seconds: 1, onBack: noop }, noop);
try {
assert.equal(render(node).all("span.title")[0]!.textContent, "Try again in 1 second");
} finally {
stop();
mock.timers.reset();
}
});
domTest("screens: the rate-limited screen's countdown opts out of #app's live region", () => {
mock.timers.enable({ apis: ["setInterval"] });
const { node, stop } = screens.rateLimited({ total: "$420.00", method: "xmr", seconds: 9, onBack: noop }, noop);
try {
const line = render(node).all("span.title")[0]!;
assert.equal(line.textContent, "Try again in 9 seconds");
// The shell is aria-live=polite, so a figure changing every second would otherwise be announced every second.
assert.equal(line.getAttribute("aria-live"), "off");
} finally {
stop();
mock.timers.reset();
}
});
domTest("screens: the rate-limited screen's stop() halts the countdown, so the timer cannot outlive the screen", () => {
mock.timers.enable({ apis: ["setInterval"] });
try {
let expired = 0;
const { node, stop } = screens.rateLimited(
{ total: "$420.00", method: "xmr", seconds: 3, onBack: noop },
() => { expired += 1; },
);
const p = render(node);
mock.timers.tick(1000);
assert.ok(p.textContent.includes("Try again in 2 seconds"));
stop();
mock.timers.tick(60_000);
assert.ok(p.textContent.includes("Try again in 2 seconds"), "the countdown must be frozen");
assert.equal(expired, 0, "and the expiry callback must never fire");
} finally {
mock.timers.reset();
}
});
domTest("screens: a disabled control does nothing when clicked", () => {
let advanced = false;
const p = render(screens.tiers({
tiers: [{ priceId: "p", badgeType: "legend", name: "Legend", price: "$70 / month", features: [], disabled: false }],
selected: undefined, onSelect: noop, onContinue: () => { advanced = true; }, onBack: noop,
}));
const go = p.all("button.primary")[0]!;
assert.ok(go.hasAttribute("disabled"));
assert.equal(go.listenerCount("click"), 1, "the handler is still attached");
go.click();
assert.equal(advanced, false, "a disabled control must not activate");
});
domTest("screens: the offline note goes beside the status on every screen still waiting", () => {
const awaitingPayment = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false,
offline: true, onCancel: noopAsync,
});
assert.ok(awaitingPayment.textContent.includes("Waiting for the payment to confirm"), "the status stays");
assert.ok(awaitingPayment.textContent.includes(screens.OFFLINE_NOTE), "and the note is added to it");
const awaitingConfirmation = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined,
order: unpaidRecord(), gaveUp: false, offline: true, onCheckAgain: noop,
}));
assert.ok(awaitingConfirmation.textContent.includes(screens.OFFLINE_NOTE));
const windowClosed = render(screens.windowClosed({ onNewInvoice: noop,
order: unpaidRecord({ status: "expired" }), invoice: { status: "expired" }, offline: true,
}));
assert.ok(windowClosed.textContent.includes(screens.OFFLINE_NOTE));
});
domTest("screens: nothing says it will keep checking when it is not", () => {
const online = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
assert.ok(!online.textContent.includes(screens.OFFLINE_NOTE), "absent is online");
const gaveUp = render(screens.awaitingConfirmation({ invoice: undefined, method: undefined,
order: unpaidRecord(), gaveUp: true, offline: true, onCheckAgain: noop,
}));
assert.ok(!gaveUp.textContent.includes(screens.OFFLINE_NOTE),
"the loop has ended: this screen keeps checking nothing");
const codeIssued = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true }));
assert.ok(!codeIssued.textContent.includes(screens.OFFLINE_NOTE));
});
domTest("screens: a part-paid invoice asks for the remainder, everywhere it names a figure", () => {
const full = renderAwaitingPayment({
order: unpaidRecord(), invoice: openXmr, method: "xmr", nowMs: NOW,
resumed: false, onCancel: noopAsync,
});
assert.equal(full.all("svg.qr").length, 1, "an untouched invoice still scans");
const part = renderAwaitingPayment({
order: unpaidRecord(),
invoice: { ...openXmr, cryptoAmountPaid: "0.741", cryptoAmountDue: "0.745", paidInFull: false },
method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
// The provider says 0.745 is still due, more than the difference, because a network fee lands with the first partial payment.
const text = part.textContent;
assert.ok(text.includes("Send 0.745 XMR"), `the heading asks the provider figure: ${text.slice(0, 80)}`);
assert.ok(text.includes("We have seen 0.741 XMR of 1.482 XMR"), "and still says what arrived");
const link = part.all("a.wallet-link")[0]?.getAttribute("href") ?? "";
assert.equal(link, `monero:${openXmr.address!}?tx_amount=0.745`,
"the wallet link carries it too - 0.745, not the 0.741 a subtraction would give");
assert.equal(part.all("svg.qr").length, 1, "and it is scannable again, now that the figure is right");
assert.equal(part.all("p.awaiting")[0]!.textContent, "Waiting for the rest of the payment",
"the status says what is actually being waited for, not that the invoice is confirming");
});
domTest("screens: the provider figure decides, and zero is never a figure to send", () => {
const draw = (paid: string, due: string | undefined) => renderAwaitingPayment({
order: unpaidRecord(),
invoice: { ...openXmr, cryptoAmountPaid: paid, paidInFull: false, ...(due === undefined ? {} : { cryptoAmountDue: due }) },
method: "xmr", nowMs: NOW, resumed: false, onCancel: noopAsync,
});
const zero = draw("1.482", "0.00000000");
assert.ok(!zero.textContent.includes("Send 0"), `never a zero to send: ${zero.textContent.slice(0, 80)}`);
assert.ok(zero.textContent.includes(screens.PAID_IN_FULL_TITLE), "it says the amount has arrived");
assert.ok(zero.textContent.includes("Nothing more is owed"), "and says so in the notice too");
assert.equal(zero.all("p.awaiting")[0]!.textContent, "Waiting for the payment to confirm",
"with nothing left to send, confirmation is what is being waited for");
assert.ok(!zero.textContent.includes("Send the rest"), "asking for more of a covered invoice is the bug");
assert.equal(zero.all("svg.qr").length, 0, "and offers nothing to scan");
for (const [name, due] of [["absent", undefined], ["unreadable", "1,4"]] as const) {
const unknown = draw("0.741", due);
assert.ok(unknown.textContent.includes(screens.PART_PAID_TITLE), `${name}: stays part-paid`);
assert.ok(!unknown.textContent.includes(screens.PAID_IN_FULL_TITLE), `${name}: claims nothing`);
assert.equal(unknown.all("svg.qr").length, 0, `${name}: no figure, no scan`);
assert.equal(unknown.all("p.awaiting")[0]!.textContent, "Waiting for the rest of the payment",
`${name}: the notice asks for the rest, so the status cannot say the payment is confirming`);
}
for (const [name, screen] of [["covered", zero], ["unknown", draw("0.741", undefined)]] as const) {
assert.ok(!screen.textContent.includes("Amount in"), `${name}: no amount field with no amount to put in it`);
}
});
domTest("screens: the history list does not promise codes are kept where nothing is kept", () => {
const rows = historyRows([record({ orderId: "a", status: "paid", code: HELD_CODE })]);
const kept = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true, rows, onOpen: noop, onStart: noop }));
assert.ok(kept.textContent.includes("in this browser, and nowhere else"));
const losing = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: false, rows, onOpen: noop, onStart: noop }));
assert.ok(!losing.textContent.includes("in this browser, and nowhere else"),
`nothing is kept, so nothing may say it is: ${losing.textContent.slice(0, 160)}`);
assert.ok(losing.textContent.includes("cannot save anything new"));
});
domTest("screens: the order summary warns before the money, not after", () => {
const base = {
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr" as const,
onSelect: noop, onPay: noop, onBack: noop,
};
const keeping = render(screens.orderSummary({ canKeepTheCode: true, ...base }));
assert.ok(!keeping.textContent.includes(screens.NOT_KEPT_TITLE));
const losing = render(screens.orderSummary({ canKeepTheCode: false, ...base }));
assert.ok(losing.textContent.includes(screens.NOT_KEPT_TITLE),
`the buyer must be told before paying: ${losing.textContent.slice(0, 200)}`);
assert.ok(losing.textContent.includes("copy the code as soon as it appears"));
assert.ok(losing.all("button").some((b) => b.textContent.startsWith("Pay ")),
"and it is a warning, not a refusal: the buyer may still pay");
});