mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-26 08:02:44 +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>
155 lines
7.8 KiB
TypeScript
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"));
|
|
}
|
|
});
|