mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 22:18:36 +00:00
* 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>
599 lines
30 KiB
TypeScript
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");
|
|
});
|