mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 04:48:58 +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>
577 lines
23 KiB
TypeScript
577 lines
23 KiB
TypeScript
import { timedTest } from "./boot.js";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import vm from "node:vm";
|
|
|
|
const swTest = timedTest(2000);
|
|
// The shell's deadline is 3s, so the test that waits it out needs longer.
|
|
const stalledTest = timedTest(8000);
|
|
|
|
const ORIGIN = "https://badges.simplex.chat";
|
|
const WORKER_SOURCE = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
|
|
const SHELL_HTML = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
|
|
|
|
// Anubis serves its challenge as HTML at the page's own path.
|
|
const ANUBIS_CHALLENGE = `<!doctype html><html><head><title>Making sure you are not a bot</title></head>
|
|
<body><script id="anubis_challenge" type="application/json">{"rules":{"difficulty":4}}</script>
|
|
<script async type="module" src="/.within.website/x/cmd/anubis/static/js/main.mjs"></script></body></html>`;
|
|
|
|
interface FakeResponse {
|
|
url: string;
|
|
ok: boolean;
|
|
status: number;
|
|
body: string;
|
|
bodyUsed: boolean;
|
|
clone(): FakeResponse;
|
|
text(): Promise<string>;
|
|
}
|
|
|
|
// A single-use body, since text() disturbs it and Cache.put rejects a disturbed body, as a browser does.
|
|
function response(url: string, status: number, body: string): FakeResponse {
|
|
let used = false;
|
|
return {
|
|
url, status, body,
|
|
ok: status >= 200 && status < 300,
|
|
get bodyUsed() { return used; },
|
|
clone() {
|
|
if (used) throw new TypeError("Response body is already used");
|
|
return response(url, status, body);
|
|
},
|
|
async text() {
|
|
if (used) throw new TypeError("Response body is already used");
|
|
used = true;
|
|
return body;
|
|
},
|
|
};
|
|
}
|
|
|
|
function keyOf(key: unknown): string {
|
|
const raw = typeof key === "string" ? key : String((key as { url: string }).url);
|
|
return new URL(raw, `${ORIGIN}/`).href;
|
|
}
|
|
|
|
function methodOf(key: unknown): string {
|
|
return typeof key === "string" ? "GET" : String((key as { method?: string }).method ?? "GET");
|
|
}
|
|
|
|
class FakeCache {
|
|
readonly entries = new Map<string, FakeResponse>();
|
|
readonly matched: string[] = [];
|
|
readonly written: string[] = [];
|
|
|
|
constructor(private readonly net: FakeNet) {}
|
|
|
|
async match(key: unknown): Promise<FakeResponse | undefined> {
|
|
this.matched.push(keyOf(key));
|
|
// The Cache API matches GET only, whatever is stored under the URL.
|
|
if (methodOf(key) !== "GET") return undefined;
|
|
return this.entries.get(keyOf(key));
|
|
}
|
|
|
|
async put(key: unknown, res: FakeResponse): Promise<void> {
|
|
if (res.bodyUsed) throw new TypeError("Failed to execute 'put' on 'Cache': Response body is already used");
|
|
this.written.push(keyOf(key));
|
|
this.entries.set(keyOf(key), res);
|
|
}
|
|
|
|
// The whole set is added or none of it is.
|
|
async addAll(keys: readonly string[]): Promise<void> {
|
|
const got = await Promise.all(keys.map(async (k) => [k, await this.net.fetch(k)] as const));
|
|
const bad = got.find(([, res]) => !res.ok);
|
|
if (bad) throw new Error(`addAll: ${bad[0]} answered ${bad[1].status}`);
|
|
for (const [k, res] of got) await this.put(k, res);
|
|
}
|
|
|
|
async keys(): Promise<string[]> { return [...this.entries.keys()]; }
|
|
}
|
|
|
|
class FakeCacheStorage {
|
|
readonly stores = new Map<string, FakeCache>();
|
|
readonly opened: string[] = [];
|
|
readonly deleted: string[] = [];
|
|
|
|
constructor(private readonly net: FakeNet) {}
|
|
|
|
async open(name: string): Promise<FakeCache> {
|
|
this.opened.push(name);
|
|
const existing = this.stores.get(name);
|
|
if (existing !== undefined) return existing;
|
|
const made = new FakeCache(this.net);
|
|
this.stores.set(name, made);
|
|
return made;
|
|
}
|
|
|
|
async keys(): Promise<string[]> { return [...this.stores.keys()]; }
|
|
|
|
async delete(name: string): Promise<boolean> {
|
|
this.deleted.push(name);
|
|
return this.stores.delete(name);
|
|
}
|
|
}
|
|
|
|
class FakeNet {
|
|
readonly requests: Array<{ url: string; init?: Record<string, unknown> }> = [];
|
|
readonly routes = new Map<string, { status: number; body: string }>();
|
|
offline = false;
|
|
|
|
readonly stalled = new Set<string>();
|
|
|
|
serve(path: string, status: number, body: string): void {
|
|
this.routes.set(path, { status, body });
|
|
}
|
|
|
|
stall(path: string): void {
|
|
this.stalled.add(path);
|
|
}
|
|
|
|
readonly fetch = async (input: unknown, init?: Record<string, unknown>): Promise<FakeResponse> => {
|
|
const url = keyOf(input);
|
|
this.requests.push(init ? { url, init } : { url });
|
|
if (this.offline) throw new TypeError("Failed to fetch");
|
|
const path = url.slice(ORIGIN.length);
|
|
if (this.stalled.has(path)) return new Promise<FakeResponse>(() => { /* never answers */ });
|
|
const hit = this.routes.get(path);
|
|
return hit === undefined
|
|
? response(url, 404, "not found")
|
|
: response(url, hit.status, hit.body);
|
|
};
|
|
|
|
get paths(): string[] { return this.requests.map((r) => r.url.slice(ORIGIN.length)); }
|
|
}
|
|
|
|
interface WorkerExports {
|
|
BUILD: string;
|
|
CACHE: string;
|
|
ASSETS: string;
|
|
SHELL: string;
|
|
ENTRY: string;
|
|
PRECACHE: string[];
|
|
strategyFor(url: URL): string;
|
|
}
|
|
|
|
interface Rig {
|
|
sw: WorkerExports;
|
|
caches: FakeCacheStorage;
|
|
net: FakeNet;
|
|
calls: { skipWaiting: number; claim: number };
|
|
install(): Promise<void>;
|
|
activate(): Promise<void>;
|
|
request(url: string, method?: string): Promise<Served>;
|
|
}
|
|
|
|
interface Served {
|
|
responded: boolean;
|
|
body?: string;
|
|
status?: number;
|
|
}
|
|
|
|
// Loads sw.js into a fresh context so one test's top-level state cannot leak into another's.
|
|
interface TimerSpy {
|
|
set: (id: number) => void;
|
|
clear: (id: number) => void;
|
|
}
|
|
|
|
function rig(options: { shell?: string; shellStatus?: number; missing?: readonly string[]; timers?: TimerSpy } = {}): Rig {
|
|
const net = new FakeNet();
|
|
const caches = new FakeCacheStorage(net);
|
|
const calls = { skipWaiting: 0, claim: 0 };
|
|
const listeners = new Map<string, Array<(event: unknown) => void>>();
|
|
|
|
const self = {
|
|
location: new URL(`${ORIGIN}/`),
|
|
addEventListener(type: string, fn: (event: unknown) => void) {
|
|
const list = listeners.get(type) ?? [];
|
|
list.push(fn);
|
|
listeners.set(type, list);
|
|
},
|
|
skipWaiting: () => { calls.skipWaiting++; },
|
|
clients: { claim: async () => { calls.claim++; } },
|
|
} as Record<string, unknown>;
|
|
|
|
const spy = options.timers;
|
|
const watchedSetTimeout = (fn: () => void, ms: number): NodeJS.Timeout => {
|
|
const handle = setTimeout(fn, ms);
|
|
spy?.set(Number(handle));
|
|
return handle;
|
|
};
|
|
const watchedClearTimeout = (handle: NodeJS.Timeout): void => {
|
|
spy?.clear(Number(handle));
|
|
clearTimeout(handle);
|
|
};
|
|
const sandbox: Record<string, unknown> = {
|
|
self, caches, fetch: net.fetch, URL, console,
|
|
setTimeout: watchedSetTimeout, clearTimeout: watchedClearTimeout,
|
|
};
|
|
sandbox.globalThis = sandbox;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(WORKER_SOURCE, sandbox, { filename: "public/sw.js" });
|
|
const sw = self.sw as WorkerExports;
|
|
|
|
net.serve("/", options.shellStatus ?? 200, options.shell ?? SHELL_HTML);
|
|
for (const url of sw.PRECACHE) {
|
|
if (options.missing?.includes(url) === true) continue;
|
|
net.serve(url, 200, `/* ${url} */`);
|
|
}
|
|
|
|
async function fire(type: string, event: Record<string, unknown>): Promise<void> {
|
|
const waited: Array<Promise<unknown>> = [];
|
|
const withWait = { ...event, waitUntil: (p: Promise<unknown>) => { waited.push(p); } };
|
|
for (const fn of listeners.get(type) ?? []) fn(withWait);
|
|
await Promise.all(waited);
|
|
}
|
|
|
|
return {
|
|
sw, caches, net, calls,
|
|
install: () => fire("install", {}),
|
|
activate: () => fire("activate", {}),
|
|
request: async (url, method = "GET") => {
|
|
let answer: Promise<FakeResponse> | undefined;
|
|
const request = { url: keyOf(url), method };
|
|
for (const fn of listeners.get("fetch") ?? []) {
|
|
fn({ request, respondWith: (p: Promise<FakeResponse>) => { answer = p; } });
|
|
}
|
|
if (answer === undefined) return { responded: false };
|
|
const res = await answer;
|
|
return { responded: true, body: res.body, status: res.status };
|
|
},
|
|
};
|
|
}
|
|
|
|
swTest("sw: the shell and the worker name the same build", () => {
|
|
const r = rig();
|
|
assert.match(r.sw.BUILD, /^[0-9a-f]{16}$/, "the build hash is content-derived by build.js");
|
|
assert.ok(SHELL_HTML.includes(`src="${r.sw.ENTRY}"`),
|
|
"index.html must load THIS build's entry module, or a cache-first shell would import another build's");
|
|
assert.ok(SHELL_HTML.includes(`${r.sw.ASSETS}styles.css`));
|
|
const hashes = new Set([...SHELL_HTML.matchAll(/\/assets\/([0-9a-f]{16})\//g)].map((m) => m[1]));
|
|
assert.deepEqual([...hashes], [r.sw.BUILD], "the shell names exactly one build, and it is this one");
|
|
});
|
|
|
|
swTest("sw: the precache is explicit URLs, every compiled module, all under one hash", () => {
|
|
const r = rig();
|
|
const modules = readdirSync(new URL("../../src", import.meta.url))
|
|
.filter((f) => f.endsWith(".ts"))
|
|
.map((f) => `${r.sw.ASSETS}${f.replace(/\.ts$/, ".js")}`);
|
|
const images = readdirSync(new URL("../../public/img", import.meta.url))
|
|
.filter((f) => f.endsWith(".png") || f.endsWith(".svg")).map((f) => `${r.sw.ASSETS}${f}`);
|
|
const fonts = readdirSync(new URL("../../public/fonts", import.meta.url))
|
|
.filter((f) => f.endsWith(".woff2")).map((f) => `${r.sw.ASSETS}${f}`);
|
|
assert.ok(images.length > 0, "the hero has to be somewhere for the worker to precache");
|
|
const expected = [`${r.sw.ASSETS}styles.css`, `${r.sw.ASSETS}init.js`, ...modules, ...images, ...fonts].sort();
|
|
assert.deepEqual([...r.sw.PRECACHE].sort(), expected,
|
|
"a module missing here is a page that half-works offline; one under another hash is a skew");
|
|
assert.ok(!r.sw.PRECACHE.includes("/"),
|
|
"`/` is fetched and checked at install, never added blind with the rest");
|
|
});
|
|
|
|
swTest("sw: /api/* is network-only, on every form the two endpoints take", () => {
|
|
const { strategyFor } = rig().sw;
|
|
for (const path of [
|
|
"/api", "/api/invoice", "/api/invoice/inv_9f3a",
|
|
"/api/invoice/inv_9f3a?wait=open", "/api/invoice/inv_9f3a?wait=expired", "/api/anything/else",
|
|
]) {
|
|
assert.equal(strategyFor(new URL(path, ORIGIN)), "network-only",
|
|
`${path} must never be cached: a stale paid shows a code for an order that later failed`);
|
|
}
|
|
});
|
|
|
|
swTest("sw: / is the shell whatever query or hash it carries", () => {
|
|
const { strategyFor } = rig().sw;
|
|
for (const path of ["/", "/index.html", "/?order=inv_9f3a", "/#/codes", "/?order=inv_9f3a#/x"]) {
|
|
assert.equal(strategyFor(new URL(path, ORIGIN)), "shell", path);
|
|
}
|
|
});
|
|
|
|
swTest("sw: only THIS build's assets are cache-first", () => {
|
|
const r = rig();
|
|
assert.equal(r.sw.strategyFor(new URL(`${r.sw.ASSETS}main.js`, ORIGIN)), "asset");
|
|
assert.equal(r.sw.strategyFor(new URL(`${r.sw.ASSETS}styles.css`, ORIGIN)), "asset");
|
|
assert.equal(r.sw.strategyFor(new URL("/assets/0000000000000000/main.js", ORIGIN)), "bypass",
|
|
"another build's URL is not ours to answer: its cache is gone");
|
|
});
|
|
|
|
swTest("sw: js.stripe.com is never this worker's business", () => {
|
|
const { strategyFor } = rig().sw;
|
|
for (const url of [
|
|
"https://js.stripe.com/v3/", "https://js.stripe.com/basil/stripe.js",
|
|
"https://api.stripe.com/v1/payment_intents", "https://hooks.stripe.com/3d_secure",
|
|
]) {
|
|
assert.equal(strategyFor(new URL(url)), "bypass", `${url} — Stripe forbids caching or self-hosting it`);
|
|
}
|
|
});
|
|
|
|
swTest("sw: anything else same-origin is left to the browser", () => {
|
|
const { strategyFor } = rig().sw;
|
|
for (const path of ["/sw.js", "/favicon.ico", "/webhooks/stripe", "/styles.css", "/src/main.js"]) {
|
|
assert.equal(strategyFor(new URL(path, ORIGIN)), "bypass", path);
|
|
}
|
|
});
|
|
|
|
swTest("sw: install precaches the shell and this build's assets, and nothing else", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
assert.deepEqual([...(await cache.keys())].sort(),
|
|
[`${ORIGIN}/`, ...r.sw.PRECACHE.map((u) => `${ORIGIN}${u}`)].sort());
|
|
assert.deepEqual([...r.caches.stores.keys()], [r.sw.CACHE], "one cache, named for the build");
|
|
const shellRequest = r.net.requests.find((q) => q.url === `${ORIGIN}/`)!;
|
|
assert.equal(shellRequest.init?.cache, "reload",
|
|
"the shell is re-read rather than taken from the HTTP cache it may already be stale in");
|
|
});
|
|
|
|
swTest("sw: install refuses a document that is not this build's shell", async () => {
|
|
const r = rig({ shell: ANUBIS_CHALLENGE });
|
|
await assert.rejects(r.install(), /not this build's shell/);
|
|
assert.equal(r.caches.stores.size, 0, "nothing survives: a challenge page enshrined as the shell is permanent");
|
|
assert.ok(r.caches.deleted.includes(r.sw.CACHE));
|
|
});
|
|
|
|
swTest("sw: and a challenge page reaches no cache even for a moment", async () => {
|
|
const r = rig({ shell: ANUBIS_CHALLENGE });
|
|
await r.install().catch(() => { /* asserted above */ });
|
|
for (const store of r.caches.stores.values()) {
|
|
for (const res of store.entries.values()) {
|
|
assert.ok(!res.body.includes("anubis_challenge"), "the challenge is in a cache");
|
|
}
|
|
}
|
|
});
|
|
|
|
swTest("sw: install fails, and leaves nothing, when the shell does not load", async () => {
|
|
for (const status of [403, 503]) {
|
|
const r = rig({ shellStatus: status, shell: "" });
|
|
await assert.rejects(r.install(), new RegExp(`answered ${status}`));
|
|
assert.equal(r.caches.stores.size, 0);
|
|
}
|
|
});
|
|
|
|
swTest("sw: a precache that cannot be completed leaves no half-installed cache", async () => {
|
|
const r = rig({ missing: [`${rig().sw.ASSETS}flow.js`] });
|
|
await assert.rejects(r.install(), /flow\.js answered 404/);
|
|
assert.equal(r.caches.stores.size, 0,
|
|
"a half-filled cache would serve a shell whose modules 404 for as long as the build lived");
|
|
});
|
|
|
|
swTest("sw: the shell is cloned before it is read, or no install could ever store it", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
const stored = (await cache.match("/"))!;
|
|
assert.equal(stored.body, SHELL_HTML);
|
|
assert.equal(stored.bodyUsed, false,
|
|
"what was put in the cache is the undisturbed response: reading it to check the marker must use a clone");
|
|
});
|
|
|
|
swTest("sw: a failed RE-INSTALL leaves the shipped build's cache untouched", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const before = [...(await r.caches.stores.get(r.sw.CACHE)!.keys())].sort();
|
|
// A background update check that meets the challenge, a 5xx, or nothing.
|
|
r.net.serve("/", 200, ANUBIS_CHALLENGE);
|
|
await assert.rejects(r.install(), /not this build's shell/);
|
|
const cache = r.caches.stores.get(r.sw.CACHE);
|
|
assert.ok(cache !== undefined,
|
|
"deleting this would take offline support from the build that is working");
|
|
assert.deepEqual([...(await cache.keys())].sort(), before, "and every entry is still in it");
|
|
assert.ok(!r.caches.deleted.includes(r.sw.CACHE));
|
|
});
|
|
|
|
swTest("sw: a re-install with no network at all leaves it alone too", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
r.net.offline = true;
|
|
await assert.rejects(r.install(), TypeError);
|
|
// Read back through the storage, not through a cache object captured before:
|
|
// deleting a cache the worker holds a handle to is exactly the bug.
|
|
const cache = r.caches.stores.get(r.sw.CACHE);
|
|
assert.ok(cache !== undefined, "the buyer on a train keeps their page");
|
|
assert.equal((await cache.match("/"))?.body, SHELL_HTML);
|
|
});
|
|
|
|
swTest("sw: a FIRST install that fails still leaves nothing behind", async () => {
|
|
const r = rig({ shell: ANUBIS_CHALLENGE });
|
|
await assert.rejects(r.install(), /not this build's shell/);
|
|
assert.equal(r.caches.stores.size, 0, "there was no build here to protect");
|
|
});
|
|
|
|
swTest("sw: install skipWaits, so a redeploy does not wait for every tab to close", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
assert.equal(r.calls.skipWaiting, 1,
|
|
"without this the new worker sits in `waiting` and the old build is served indefinitely");
|
|
});
|
|
|
|
swTest("sw: an install that fails does not skipWaiting over a half-filled cache", async () => {
|
|
const r = rig({ shellStatus: 500 });
|
|
await assert.rejects(() => r.install());
|
|
assert.equal(r.calls.skipWaiting, 0,
|
|
"taking over with no cache would serve a shell whose modules are not there");
|
|
});
|
|
|
|
swTest("sw: activation deletes every cache whose hash is not this build", async () => {
|
|
const r = rig();
|
|
await r.caches.open("sb-0000000000000000");
|
|
await r.caches.open("sb-1111111111111111");
|
|
await r.install();
|
|
await r.activate();
|
|
assert.deepEqual([...r.caches.stores.keys()], [r.sw.CACHE], "the old builds are gone");
|
|
assert.deepEqual(r.caches.deleted.sort(), ["sb-0000000000000000", "sb-1111111111111111"]);
|
|
});
|
|
|
|
swTest("sw: activation keeps this build's cache, which is what offline is made of", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
await r.activate();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
assert.ok((await cache.keys()).length > 0);
|
|
assert.ok(!r.caches.deleted.includes(r.sw.CACHE));
|
|
});
|
|
|
|
swTest("sw: activation does not claim the open pages either", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
await r.activate();
|
|
assert.equal(r.calls.claim, 0, "a page keeps the build it loaded with until it is loaded again");
|
|
});
|
|
|
|
swTest("sw: an API request is not answered here, and never touches the Cache API", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
const openedBefore = r.caches.opened.length;
|
|
cache.matched.length = 0;
|
|
cache.written.length = 0;
|
|
|
|
for (const [url, method] of [
|
|
[`${ORIGIN}/api/invoice`, "POST"],
|
|
[`${ORIGIN}/api/invoice/inv_9f3a`, "GET"],
|
|
[`${ORIGIN}/api/invoice/inv_9f3a?wait=open`, "GET"],
|
|
] as const) {
|
|
const served = await r.request(url, method);
|
|
assert.equal(served.responded, false, `${method} ${url} must go to the network as if no worker existed`);
|
|
}
|
|
assert.deepEqual(cache.matched, [], "not even a read: a cached paid would show a code for an order that failed");
|
|
assert.deepEqual(cache.written, [], "and nothing about money is ever put into the Cache API");
|
|
assert.equal(r.caches.opened.length, openedBefore, "no cache is so much as opened for /api/*");
|
|
});
|
|
|
|
swTest("sw: a page load is answered from the cache when the network is gone", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
r.net.offline = true;
|
|
for (const url of ["/", "/?order=inv_9f3a", "/?order=inv_9f3a#/x", "/index.html"]) {
|
|
const served = await r.request(`${ORIGIN}${url}`);
|
|
assert.equal(served.responded, true, url);
|
|
assert.equal(served.body, SHELL_HTML, `${url} — ?order= is a page, not a key`);
|
|
}
|
|
});
|
|
|
|
swTest("sw: a redeployed shell reaches the page on a reload, with the old worker still active", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const REDEPLOYED = SHELL_HTML.replace("main.js", "main.js?v=next");
|
|
r.net.serve("/", 200, REDEPLOYED);
|
|
for (const url of ["/", "/?order=inv_9f3a", "/index.html"]) {
|
|
const served = await r.request(`${ORIGIN}${url}`);
|
|
assert.equal(served.responded, true, url);
|
|
assert.equal(served.body, REDEPLOYED, `${url} kept serving the cached shell after a redeploy`);
|
|
}
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
assert.deepEqual(cache.written.filter((w) => w === "/"), []);
|
|
});
|
|
|
|
swTest("sw: the shell's deadline is cleared on every path, so no request leaves a timer armed", async () => {
|
|
const armed: number[] = [];
|
|
const r = rig({ timers: { set: (id) => armed.push(id), clear: (id) => { armed.splice(armed.indexOf(id), 1); } } });
|
|
await r.install();
|
|
r.net.offline = true;
|
|
await r.request(`${ORIGIN}/`);
|
|
assert.deepEqual(armed, [], "the offline path left a timer running");
|
|
r.net.offline = false;
|
|
await r.request(`${ORIGIN}/`);
|
|
assert.deepEqual(armed, [], "and so did the answered path");
|
|
});
|
|
|
|
stalledTest("sw: a network that accepts and never answers falls back rather than hanging the page", async () => {
|
|
// fetch does not reject on a stalled connection and navigator.onLine still says online, so only a deadline serves the page.
|
|
const r = rig();
|
|
await r.install();
|
|
r.net.stall("/");
|
|
const started = Date.now();
|
|
const served = await r.request(`${ORIGIN}/`);
|
|
assert.equal(served.responded, true);
|
|
assert.equal(served.body, SHELL_HTML, "the precached shell is what a stalled network is for");
|
|
assert.ok(Date.now() - started < 10_000, "and it must not wait out the browser's own timeout");
|
|
});
|
|
|
|
swTest("sw: a shell the listener cannot serve falls back to the cache, not to an error", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
r.net.serve("/", 503, "the listener is restarting");
|
|
const served = await r.request(`${ORIGIN}/`);
|
|
assert.equal(served.responded, true);
|
|
assert.equal(served.body, SHELL_HTML, "a 5xx must not take the page away from a buyer");
|
|
});
|
|
|
|
swTest("sw: the shell and every module are served with the network gone", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
r.net.offline = true;
|
|
const before = r.net.requests.length;
|
|
for (const url of r.sw.PRECACHE) {
|
|
const served = await r.request(`${ORIGIN}${url}`);
|
|
assert.equal(served.responded, true, url);
|
|
assert.equal(served.body, `/* ${url} */`, url);
|
|
}
|
|
assert.equal(r.net.requests.length, before, "an asset is cache-first: offline is the same path, not a special case");
|
|
});
|
|
|
|
swTest("sw: an asset this build never precached falls through to the network, uncached", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
cache.written.length = 0;
|
|
r.net.serve(`${r.sw.ASSETS}later.js`, 200, "/* a module added after this worker shipped */");
|
|
const served = await r.request(`${ORIGIN}${r.sw.ASSETS}later.js`);
|
|
assert.equal(served.responded, true);
|
|
assert.equal(served.body, "/* a module added after this worker shipped */");
|
|
assert.deepEqual(cache.written, [], "this worker caches one build at install and writes nothing at request time");
|
|
});
|
|
|
|
swTest("sw: nothing whatsoever is written to the Cache API after install", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
cache.written.length = 0;
|
|
await r.request(`${ORIGIN}/`);
|
|
await r.request(`${ORIGIN}/?order=inv_9f3a`);
|
|
await r.request(`${ORIGIN}${r.sw.ASSETS}main.js`);
|
|
await r.request(`${ORIGIN}/api/invoice/inv_9f3a`);
|
|
await r.request(`${ORIGIN}/api/invoice`, "POST");
|
|
await r.request("https://js.stripe.com/v3/");
|
|
await r.request(`${ORIGIN}/favicon.ico`);
|
|
assert.deepEqual(cache.written, []);
|
|
});
|
|
|
|
swTest("sw: a request that is not a GET is never answered from a cache", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const cache = r.caches.stores.get(r.sw.CACHE)!;
|
|
cache.written.length = 0;
|
|
for (const url of ["/", `${r.sw.ASSETS}main.js`]) {
|
|
for (const method of ["POST", "HEAD"]) {
|
|
const served = await r.request(`${ORIGIN}${url}`, method);
|
|
assert.equal(served.responded, false, `${method} ${url} must reach the service`);
|
|
}
|
|
}
|
|
assert.deepEqual(cache.written, [], "and none of them wrote anything either");
|
|
});
|
|
|
|
swTest("sw: Stripe.js is fetched by the page, never by this worker", async () => {
|
|
const r = rig();
|
|
await r.install();
|
|
const before = r.net.requests.length;
|
|
const served = await r.request("https://js.stripe.com/v3/");
|
|
assert.equal(served.responded, false, "another origin is answered as if no worker existed");
|
|
assert.equal(r.net.requests.length, before, "and the worker does not fetch it itself");
|
|
});
|