A manifest and icons rendered from icon.svg make the app installable. The native build emits a service worker that precaches exactly the built files and passes everything else to the network, so ROMs never reach its cache. New versions wait for the player's Update, which saves before reloading. Savegames offers installation, iPhone steps and persistent storage, and a full disk gets its own save error. Acceptance on physical iPhone and Android devices remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { serviceWorkerSource } from "../scripts/service-worker.mjs";
|
|
|
|
/** Run the generated worker against a fake CacheStorage and network. */
|
|
function worker(files: string[], version: string, existing: string[] = []) {
|
|
const stores = new Map<string, Map<string, Response>>();
|
|
for (const name of existing) stores.set(name, new Map());
|
|
const caches = {
|
|
open: async (name: string) => {
|
|
if (!stores.has(name)) stores.set(name, new Map());
|
|
const store = stores.get(name)!;
|
|
const key = (request: Request | string) =>
|
|
new URL(
|
|
typeof request === "string" ? request : request.url,
|
|
"https://crystal.test/game/sw.js",
|
|
).href;
|
|
return {
|
|
addAll: async (urls: string[]) => {
|
|
for (const url of urls)
|
|
store.set(key(url), new Response(`cached ${url}`));
|
|
},
|
|
match: async (
|
|
request: Request | string,
|
|
options?: { ignoreSearch?: boolean },
|
|
) => {
|
|
const url = new URL(key(request));
|
|
if (options?.ignoreSearch) url.search = "";
|
|
return store.get(url.href)?.clone();
|
|
},
|
|
};
|
|
},
|
|
keys: async () => [...stores.keys()],
|
|
delete: async (name: string) => stores.delete(name),
|
|
};
|
|
const fetch = vi.fn(
|
|
async (request: Request) => new Response(`network ${request.url}`),
|
|
);
|
|
const listeners = new Map<string, (event: unknown) => void>();
|
|
const self = {
|
|
location: new URL("https://crystal.test/game/sw.js"),
|
|
addEventListener: (type: string, listener: (event: unknown) => void) =>
|
|
listeners.set(type, listener),
|
|
clients: { claim: vi.fn(async () => {}) },
|
|
skipWaiting: vi.fn(),
|
|
};
|
|
new Function("self", "caches", "fetch", serviceWorkerSource(files, version))(
|
|
self,
|
|
caches,
|
|
fetch,
|
|
);
|
|
const run = async (type: string, event: Record<string, unknown> = {}) => {
|
|
let pending: Promise<unknown> = Promise.resolve();
|
|
let response: Promise<Response> | undefined;
|
|
listeners.get(type)!({
|
|
...event,
|
|
waitUntil: (promise: Promise<unknown>) => (pending = promise),
|
|
respondWith: (promise: Promise<Response>) => (response = promise),
|
|
});
|
|
await pending;
|
|
return response && (await response);
|
|
};
|
|
const request = (
|
|
url: string,
|
|
init: { mode?: string; method?: string } = {},
|
|
) =>
|
|
({
|
|
url,
|
|
method: init.method ?? "GET",
|
|
mode: init.mode ?? "no-cors",
|
|
}) as unknown as Request;
|
|
return { stores, fetch, self, run, request };
|
|
}
|
|
|
|
describe("the app shell service worker", () => {
|
|
const files = [
|
|
"assets/index-abc.js",
|
|
"assets/index-abc.css",
|
|
"icon.svg",
|
|
"index.html",
|
|
"manifest.webmanifest",
|
|
];
|
|
|
|
it("precaches the shell, with the page as ./", async () => {
|
|
const sw = worker(files, "v1");
|
|
await sw.run("install");
|
|
expect([...sw.stores.get("crystal-shell-v1")!.keys()].sort()).toEqual(
|
|
[
|
|
"https://crystal.test/game/",
|
|
"https://crystal.test/game/assets/index-abc.css",
|
|
"https://crystal.test/game/assets/index-abc.js",
|
|
"https://crystal.test/game/icon.svg",
|
|
"https://crystal.test/game/manifest.webmanifest",
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
it("serves the shell offline and leaves other requests to the network", async () => {
|
|
const sw = worker(files, "v1");
|
|
await sw.run("install");
|
|
const page = await sw.run("fetch", {
|
|
request: sw.request("https://crystal.test/game/?resume", {
|
|
mode: "navigate",
|
|
}),
|
|
});
|
|
expect(await page!.text()).toBe("cached ./");
|
|
const script = await sw.run("fetch", {
|
|
request: sw.request("https://crystal.test/game/assets/index-abc.js?v=2"),
|
|
});
|
|
expect(await script!.text()).toBe("cached ./assets/index-abc.js");
|
|
const unknown = await sw.run("fetch", {
|
|
request: sw.request("https://crystal.test/game/missing.png"),
|
|
});
|
|
expect(await unknown!.text()).toBe(
|
|
"network https://crystal.test/game/missing.png",
|
|
);
|
|
// Other origins, other paths and writes are not intercepted at all.
|
|
for (const request of [
|
|
sw.request("https://elsewhere.test/game/icon.svg"),
|
|
sw.request("https://crystal.test/other/icon.svg"),
|
|
sw.request("https://crystal.test/game/icon.svg", { method: "POST" }),
|
|
])
|
|
expect(await sw.run("fetch", { request })).toBeUndefined();
|
|
});
|
|
|
|
it("removes older shells when it activates and waits to take over", async () => {
|
|
const sw = worker(files, "v2", ["crystal-shell-v1", "unrelated"]);
|
|
await sw.run("install");
|
|
await sw.run("activate");
|
|
expect([...sw.stores.keys()].sort()).toEqual([
|
|
"crystal-shell-v2",
|
|
"unrelated",
|
|
]);
|
|
expect(sw.self.clients.claim).toHaveBeenCalled();
|
|
expect(sw.self.skipWaiting).not.toHaveBeenCalled();
|
|
await sw.run("message", { data: "something else" });
|
|
expect(sw.self.skipWaiting).not.toHaveBeenCalled();
|
|
await sw.run("message", { data: "skip-waiting" });
|
|
expect(sw.self.skipWaiting).toHaveBeenCalledOnce();
|
|
});
|
|
});
|