Files
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

155 lines
7.8 KiB
TypeScript

import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, inViewOf, primaryOf, screenOf, settle, timedTest, until } from "./boot.js";
import { MemStorage, type StubElement } from "./stub-dom.js";
import { OFFLINE_NOTE } from "../src/screens.js";
const offlineTest = timedTest(3000);
const NOW = Date.parse("2026-08-28T12:00:00Z");
const CREATED = new Date(NOW - 9 * 60_000).toISOString();
const HELD_CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
const ADDRESS = "48HqK2XmVexampleAddress9fRtWc";
const storage = new MemStorage();
storage.setItem("sb.orders.v1", JSON.stringify([{
orderId: "inv_open", badgeType: "legend", months: 12,
createdAt: CREATED, status: "open", code: HELD_CODE,
address: ADDRESS, cryptoAmount: "1.482", cryptoCurrency: "xmr",
expiresAt: "2026-08-28T12:58:12Z", amount: 42000, currency: "usd",
}]));
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage });
const { app, fetches } = page;
// The network is set offline before the module runs, so the load itself happens with no network.
page.setOffline(true);
await import("../src/main.js");
const heading = (): string => headingOf(screenOf(app));
function noteCount(): number {
return screenOf(app).all("p.offline").filter((p) => p.textContent === OFFLINE_NOTE).length;
}
const primary = primaryOf;
// ------------------------------------------------------- resuming with no net
offlineTest("main: a load with no network resumes the payment screen from the store alone", async () => {
await until(() => heading().startsWith("Send"), "the payment screen drawn from the record");
assert.equal(heading(), "Send 1.482 XMR", "the amount is the one stored with the order");
assert.ok(screenOf(app).textContent.includes(ADDRESS), "and so is the address");
assert.ok(screenOf(app).textContent.includes("inv_open"), "with the reference, which is all support needs");
assert.equal(fetches.length >= 1, true, "the status was still asked for: only the provider knows it");
assert.equal(fetches[0]!.url, "/api/invoice/inv_open");
});
offlineTest("main: the payment screen says it is offline, beside the status and not instead of it", () => {
assert.equal(noteCount(), 1, `"${OFFLINE_NOTE}" is shown while the network is gone`);
assert.ok(screenOf(app).textContent.includes("Waiting for the payment to confirm"),
"the last known status stays rendered: a frozen screen must not read as a stalled payment");
const note = screenOf(app).all("p.offline")[0]!;
assert.equal(note.getAttribute("role"), "status", "it is announced, not just drawn");
});
offlineTest("main: the rate window is on screen offline, not silently dropped", () => {
// Without a stored expiresAt this screen would show a payable address and no window, while its last line promises the countdown lives on this URL.
assert.ok(screenOf(app).textContent.includes("$420.00 — this rate is held for 58:12"),
`the stored window and fiat figure are rendered: ${screenOf(app).textContent}`);
assert.ok(screenOf(app).textContent.includes("Bookmark this page — the address and the countdown both live on this URL."),
"which is what that line promises");
});
offlineTest("main: the code it holds is still not on the unpaid screen", () => {
assert.ok(page.storage.getItem("sb.orders.v1")!.includes(HELD_CODE), "the code is in the store");
assert.ok(!screenOf(app).serialize().includes("SB-"), "and nowhere on a screen whose order is unpaid");
});
offlineTest("main: coming back online clears the note and asks for nothing extra", async () => {
const before = fetches.length;
page.setOffline(false);
await settle();
assert.equal(noteCount(), 0, "the note goes when the network comes back");
assert.equal(heading(), "Send 1.482 XMR", "and the screen underneath it does not change");
assert.equal(fetches.length, before,
"the repaint asks nothing: the backoff is what retries, and it never stopped");
});
offlineTest("main: losing the network again puts the note back", async () => {
page.setOffline(true);
await settle();
assert.equal(noteCount(), 1);
});
// ------------------------------------- what a repaint may not bring back
offlineTest("main: leaving the payment screen stops its loop, and no repaint returns it", async () => {
page.chrome.all("button.menu-button")[0]!.click();
page.chrome.all("button.menu-item").find((b) => b.textContent === "Buy a code")!.click();
await settle();
// Land on the landing screen so the tests that follow start clean.
page.history.pushState(null, "", "/");
page.fire("popstate");
await settle();
assert.equal(heading(), "Support SimpleX", "the landing screen — and the watch loop stopped that invoice's loop on the way");
// A connectivity change repaints the waiting screen, so with none left a repaint must not put the abandoned payment screen over the wizard with no loop behind it.
for (const off of [false, true]) {
page.setOffline(off);
await settle();
assert.equal(heading(), "Support SimpleX", `an ${off ? "offline" : "online"} event must not restore the payment screen`);
assert.ok(!app.serialize().includes(ADDRESS), "the abandoned invoice is nowhere on screen");
}
});
// --------------------------------------------------- the unknown-order screen
offlineTest("main: pressing Pay offline lands on the unknown-order screen, whose copy is exactly true", async () => {
// The first four panels work offline because the catalog is compiled in.
const inView = (): StubElement => inViewOf(app);
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();
assert.equal(headingOf(inView()), "Check your order");
assert.ok(inView().textContent.includes("$420.00"), "the prices are compiled in");
const before = fetches.length;
inView().all("button.primary")[0]!.click();
await until(() => headingOf(inView()) === "That did not go through", "the invoice failure");
assert.equal(fetches.length, before + 1, "it did try: the provider must create the invoice");
assert.ok(inView().textContent.includes("The order was not created, and nothing was charged."),
"which is exactly true with no network — the request never reached the service");
assert.equal(noteCount(), 0, "the unknown-order screen is not a waiting screen: nothing here is going to keep checking");
});
offlineTest("main: the worker was registered on this load too, after it had rendered", () => {
assert.deepEqual(page.workers.registrations.map((r) => r.url), ["/sw.js"]);
assert.ok(page.workers.registrations[0]!.appChildren > 0,
"no network is not a reason to register before the shell is confirmed");
});
offlineTest("main: the history list is not replaced by the payment screen behind it either", async () => {
// The create endpoint has no idempotency key, so Try again creates a second invoice with its own payment screen.
page.setOffline(false);
page.respondWith({ status: 200, body: {
invoiceId: "inv_second", badgeType: "legend", months: 12,
amount: 42000, currency: "usd", expiresAt: "2026-08-28T13:00:00Z",
address: "8BdXsecondAddressExample", cryptoAmount: "1.500", cryptoCurrency: "xmr",
} });
inViewOf(app).all("button.primary")[0]!.click();
await until(() => heading().startsWith("Send"), "the payment screen for the second invoice");
page.history.pushState(null, "", "/#/codes");
page.fire("popstate");
await settle();
assert.equal(heading(), "Your codes");
for (const off of [true, false]) {
page.setOffline(off);
await settle();
assert.equal(heading(), "Your codes",
`an ${off ? "offline" : "online"} event must not paint a payment screen over the history`);
assert.ok(!app.serialize().includes("8BdXsecondAddressExample"));
}
});