Files
simplex-chat/apps/simplex-badge-service/web/test/main.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

599 lines
30 KiB
TypeScript

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<string, unknown>;
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<Record<string, string>>;
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(/<script type="module" src="\/assets\/[0-9a-f]{16}\/main\.js"><\/script>/.test(html),
"the offline promise: the entry module is under /assets/<buildHash>/, so the shell and its modules cannot skew");
assert.ok(html.includes("simplex.chat/contact"), "every screen carries the footer");
// If this fails after a screens.ts change, run `npm run build` and commit the regenerated public/index.html.
const build = await import(new URL("../../build.js", import.meta.url).href);
const shell = await build.prerenderShell();
assert.ok(html.includes(`<!--shell:chrome-->${shell.chromeHtml}<!--/shell:chrome-->`),
"the shell's chrome is not chrome()'s current output — rebuild");
assert.ok(html.includes(`<!--shell:app-->${shell.appHtml}<!--/shell:app-->`),
"the shell's landing is not landing()'s current output — rebuild");
});
// ------------------------------------------------------------- the chrome
mainTest("main: the chosen theme is written to <html>, and system removes it", () => {
const html = page.documentElement;
const segment = (label: string): StubElement => {
const found = page.chrome.all("button.segment").find((b) => b.textContent === label);
assert.ok(found, `the theme control has no "${label}"`);
return found;
};
assert.equal(html.hasAttribute("data-theme"), false);
segment("Dark").click();
assert.equal(html.getAttribute("data-theme"), "dark");
assert.equal(storage.getItem("sb.theme.v1"), '"dark"', "and it survives a reload");
assert.equal(segment("Dark").getAttribute("aria-pressed"), "true");
assert.equal(segment("System").getAttribute("aria-pressed"), "false");
segment("Light").click();
assert.equal(html.getAttribute("data-theme"), "light",
"an explicit light must beat an operating system set to dark, so the attribute is written");
segment("System").click();
assert.equal(html.hasAttribute("data-theme"), false,
"system is the ABSENCE of the attribute, which hands the page back to the media query");
assert.equal(storage.getItem("sb.theme.v1"), '"system"');
});
mainTest("main: [ Forget everything ] does not take the theme with it", () => {
// The wipe control lives on the codes list, so a code must exist to reach it.
storage.setItem("sb.orders.v1", JSON.stringify([{
orderId: "inv_theme", badgeType: "supporter", months: 1,
createdAt: "2026-08-28T11:02:19Z", status: "open",
}]));
page.chrome.all("button.segment").find((b) => b.textContent === "Dark")!.click();
page.confirmAnswer(true);
forgetControl(page)!.click();
assert.equal(storage.getItem("sb.orders.v1"), null, "the codes go");
assert.equal(storage.getItem("sb.theme.v1"), '"dark"',
"and a colour scheme is not something that control exists to destroy");
page.chrome.all("button.segment").find((b) => b.textContent === "System")!.click();
});
mainTest("main: an open menu takes the screen behind it out of the tree, and gives it back", () => {
const trigger = page.chrome.all("button.menu-button")[0]!;
assert.equal(app.hasAttribute("inert"), false);
trigger.click();
assert.equal(app.hasAttribute("inert"), true, "the screen is inert while the menu is over it");
trigger.click();
assert.equal(app.hasAttribute("inert"), false, "and every close gives it back");
trigger.click();
menuItem("Your codes").click();
assert.equal(app.hasAttribute("inert"), false, "or the screen it navigated to would be dead");
history.back();
});
mainTest("main: Escape closes the menu and hands focus back to the button", () => {
const trigger = page.chrome.all("button.menu-button")[0]!;
const menu = page.chrome.all("div.menu")[0]!;
trigger.click();
assert.equal(menu.hasAttribute("hidden"), false);
const before = trigger.focused;
page.press("Escape");
assert.equal(menu.hasAttribute("hidden"), true);
assert.equal(trigger.focused, before + 1, "the keyboard must not be left on a panel that is gone");
});
mainTest("main: Tab keeps the keyboard inside an open menu, and wraps at both ends", () => {
const trigger = page.chrome.all("button.menu-button")[0]!;
const items = page.chrome.all("button.segment").concat(page.chrome.all("button.menu-item"));
trigger.click();
page.press("Tab");
assert.equal(document.activeElement, items[0]);
page.press("Tab", { shiftKey: true });
assert.equal(document.activeElement, items[items.length - 1]);
page.press("Tab");
assert.equal(document.activeElement, items[0]);
page.press("Escape");
});
mainTest("main: a key press with the menu closed reaches nothing", () => {
const menu = page.chrome.all("div.menu")[0]!;
assert.equal(menu.hasAttribute("hidden"), true);
const before = page.chrome.all("button.menu-button")[0]!.focused;
page.press("Escape");
page.press("Tab");
assert.equal(page.chrome.all("button.menu-button")[0]!.focused, before, "nothing is stolen from the screen");
});
mainTest("main: the menu carries no order, on a page that holds one", () => {
assert.equal(/SB-|inv_|order=/.test(page.chrome.serialize()), false, page.chrome.serialize());
});
// ------------------------------------------------- the worker's registration
// Anubis serves its challenge as HTML at the page's own path, so a worker registered before the real shell rendered could precache the challenge as the shell permanently; these tests assert #app already held a rendered panel when register was called.
mainTest("main: the worker is registered once, and only after the shell rendered", () => {
assert.deepEqual(page.workers.registrations.map((r) => r.url), ["/sw.js"],
"one registration, of the worker at the origin's root, which is its scope");
assert.ok(page.workers.registrations[0]!.appChildren > 0,
"#app was empty when register was called: that load is not evidence of a real shell");
});
mainTest("main: what was on screen when it registered was this build's own track", () => {
assert.equal(page.workers.registrations[0]!.shell, "div.track");
});