mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
400 lines
16 KiB
JavaScript
400 lines
16 KiB
JavaScript
// SPDX-License-Identifier: 0BSD
|
|
|
|
/**
|
|
* Runtime strategy oracles: races, leaks, update prune, navigation timeout fallback.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cacheNameForBuild, SHELL_CACHE_PREFIX } from "../../meshchatx/src/frontend/js/pwa/swCachePolicy.js";
|
|
import {
|
|
createShellRuntime,
|
|
findForbiddenCachedUrls,
|
|
handleFetchEvent,
|
|
oracleExpectedStrategy,
|
|
selectCachesToDelete,
|
|
settlePreloadResponse,
|
|
shouldAttachNavigationPreload,
|
|
SHELL_FALLBACK_URL,
|
|
} from "../../meshchatx/src/frontend/js/pwa/swShellRuntime.js";
|
|
import {
|
|
decideControllerChangeReload,
|
|
isIgnorableServiceWorkerRegistrationError,
|
|
serviceWorkerRegisterOptions,
|
|
shouldRegisterServiceWorker,
|
|
unregisterServiceWorkersIfPresent,
|
|
} from "../../meshchatx/src/frontend/js/pwa/swClientRegister.js";
|
|
import { makeRequest, MemoryCacheStorage, okResponse } from "./helpers/memoryCacheStorage.js";
|
|
|
|
const ORIGIN = "https://127.0.0.1:8000";
|
|
|
|
function assertNoApiLeakOracle(cacheStorage) {
|
|
const leaked = findForbiddenCachedUrls(cacheStorage.allUrls());
|
|
expect(leaked).toEqual([]);
|
|
}
|
|
|
|
describe("swShellRuntime oracle / race / leak", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("oracle: cache-first serves cached asset and never stores API", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const cacheName = cacheNameForBuild("t1");
|
|
const assetUrl = `${ORIGIN}/assets/app-abc.js`;
|
|
let fetches = 0;
|
|
const fetchFn = vi.fn(async (request) => {
|
|
fetches += 1;
|
|
const url = typeof request === "string" ? request : request.url;
|
|
if (url.includes("/api/")) {
|
|
return okResponse('{"secret":true}', { headers: { "content-type": "application/json" } });
|
|
}
|
|
return okResponse("asset-v1");
|
|
});
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: fetchFn,
|
|
cacheName,
|
|
origin: ORIGIN,
|
|
});
|
|
|
|
const assetReq = makeRequest(assetUrl);
|
|
const first = await runtime.cacheFirst(assetReq);
|
|
expect(await first.text()).toBe("asset-v1");
|
|
const second = await runtime.cacheFirst(assetReq);
|
|
expect(await second.text()).toBe("asset-v1");
|
|
expect(fetches).toBe(1);
|
|
|
|
const apiReq = makeRequest(`${ORIGIN}/api/v1/status`);
|
|
expect(runtime.resolveFetch(apiReq)).toBeNull();
|
|
expect(oracleExpectedStrategy({ pathname: "/api/v1/status", method: "GET" })).toBe("bypass");
|
|
assertNoApiLeakOracle(caches);
|
|
});
|
|
|
|
it("oracle: failed network responses are not written to cache", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const cacheName = cacheNameForBuild("t2");
|
|
const assetUrl = `${ORIGIN}/assets/missing.js`;
|
|
const fetchFn = vi.fn(async () => new Response("nope", { status: 404 }));
|
|
const runtime = createShellRuntime({ caches, fetch: fetchFn, cacheName, origin: ORIGIN });
|
|
const response = await runtime.cacheFirst(makeRequest(assetUrl));
|
|
expect(response.status).toBe(404);
|
|
expect(caches.allUrls()).toEqual([]);
|
|
});
|
|
|
|
it("race: concurrent cache-first misses do not leak and stabilize on one body", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const cacheName = cacheNameForBuild("race");
|
|
const assetUrl = `${ORIGIN}/assets/race.js`;
|
|
let started = 0;
|
|
let release;
|
|
const gate = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const fetchFn = vi.fn(async () => {
|
|
started += 1;
|
|
await gate;
|
|
return okResponse(`body-${started}`);
|
|
});
|
|
const runtime = createShellRuntime({ caches, fetch: fetchFn, cacheName, origin: ORIGIN });
|
|
const req = makeRequest(assetUrl);
|
|
const p1 = runtime.cacheFirst(req);
|
|
const p2 = runtime.cacheFirst(req);
|
|
await vi.waitFor(() => {
|
|
expect(started).toBe(2);
|
|
});
|
|
release();
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
const t1 = await r1.text();
|
|
const t2 = await r2.text();
|
|
expect(t1.startsWith("body-")).toBe(true);
|
|
expect(t2.startsWith("body-")).toBe(true);
|
|
assertNoApiLeakOracle(caches);
|
|
const cached = await caches.match(req);
|
|
expect(cached).toBeTruthy();
|
|
expect((await cached.text()).startsWith("body-")).toBe(true);
|
|
});
|
|
|
|
it("race: prune deletes only old shell caches and keeps current", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const oldName = cacheNameForBuild("old");
|
|
const newName = cacheNameForBuild("new");
|
|
const oldCache = await caches.open(oldName);
|
|
const newCache = await caches.open(newName);
|
|
await oldCache.put(`${ORIGIN}/assets/old.js`, okResponse("old"));
|
|
await newCache.put(`${ORIGIN}/assets/new.js`, okResponse("new"));
|
|
await caches.open("unrelated-other");
|
|
|
|
const doomed = selectCachesToDelete(await caches.keys(), SHELL_CACHE_PREFIX, newName);
|
|
expect(doomed).toEqual([oldName]);
|
|
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: vi.fn(),
|
|
cacheName: newName,
|
|
origin: ORIGIN,
|
|
});
|
|
const deleted = await runtime.pruneOldShellCaches();
|
|
expect(deleted).toEqual([oldName]);
|
|
expect(await caches.keys()).toEqual(expect.arrayContaining([newName, "unrelated-other"]));
|
|
expect(await caches.keys()).not.toContain(oldName);
|
|
expect(await (await caches.open(newName)).match(`${ORIGIN}/assets/new.js`)).toBeTruthy();
|
|
});
|
|
|
|
it("edge: navigation timeout falls back to cached shell without caching API", async () => {
|
|
vi.useFakeTimers();
|
|
const caches = new MemoryCacheStorage();
|
|
const cacheName = cacheNameForBuild("nav");
|
|
const shell = okResponse("<html>shell</html>", { headers: { "content-type": "text/html" } });
|
|
await (await caches.open(cacheName)).put(SHELL_FALLBACK_URL, shell);
|
|
|
|
const fetchFn = vi.fn((_request, init = {}) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (init.signal) {
|
|
init.signal.addEventListener("abort", () => {
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
});
|
|
}
|
|
});
|
|
});
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: fetchFn,
|
|
cacheName,
|
|
origin: ORIGIN,
|
|
navTimeoutMs: 50,
|
|
});
|
|
const navReq = makeRequest(`${ORIGIN}/`, { mode: "navigate" });
|
|
const pending = runtime.networkFirstNavigation({ request: navReq });
|
|
await vi.advanceTimersByTimeAsync(60);
|
|
const response = await pending;
|
|
expect(await response.text()).toContain("shell");
|
|
assertNoApiLeakOracle(caches);
|
|
});
|
|
|
|
it("edge: cross-origin and network-only paths bypass respondWith", () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: vi.fn(),
|
|
cacheName: cacheNameForBuild("x"),
|
|
origin: ORIGIN,
|
|
});
|
|
expect(runtime.resolveFetch(makeRequest("https://evil.example/assets/x.js"))).toBeNull();
|
|
expect(runtime.resolveFetch(makeRequest(`${ORIGIN}/vendor/x.wasm`))).toBeNull();
|
|
expect(runtime.resolveFetch(makeRequest(`${ORIGIN}/api/v1/status`))).toBeNull();
|
|
expect(runtime.resolveFetch(makeRequest(`${ORIGIN}/ws`))).toBeNull();
|
|
});
|
|
|
|
it("edge: stale-while-revalidate returns cache immediately then refreshes", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const cacheName = cacheNameForBuild("swr");
|
|
const url = `${ORIGIN}/boot-theme.js`;
|
|
await (await caches.open(cacheName)).put(url, okResponse("old-theme"));
|
|
let resolveNet;
|
|
const net = new Promise((resolve) => {
|
|
resolveNet = resolve;
|
|
});
|
|
const fetchFn = vi.fn(() => net);
|
|
const runtime = createShellRuntime({ caches, fetch: fetchFn, cacheName, origin: ORIGIN });
|
|
const first = await runtime.staleWhileRevalidate(makeRequest(url));
|
|
expect(await first.text()).toBe("old-theme");
|
|
resolveNet(okResponse("new-theme"));
|
|
await vi.waitFor(async () => {
|
|
const cached = await (await caches.open(cacheName)).match(url);
|
|
expect(await cached.text()).toBe("new-theme");
|
|
});
|
|
});
|
|
|
|
it("leak oracle: findForbiddenCachedUrls catches api and ws entries", () => {
|
|
expect(
|
|
findForbiddenCachedUrls([
|
|
`${ORIGIN}/assets/a.js`,
|
|
`${ORIGIN}/api/v1/status`,
|
|
`${ORIGIN}/ws`,
|
|
"/boot-theme.js",
|
|
])
|
|
).toEqual(["/api/v1/status", "/ws"]);
|
|
});
|
|
});
|
|
|
|
describe("swClientRegister update lifecycle oracle", () => {
|
|
it("oracle: first install does not reload, update reloads once", () => {
|
|
expect(decideControllerChangeReload({ hadController: false, refreshing: false })).toEqual({
|
|
shouldReload: false,
|
|
nextRefreshing: false,
|
|
});
|
|
const first = decideControllerChangeReload({ hadController: true, refreshing: false });
|
|
expect(first).toEqual({ shouldReload: true, nextRefreshing: true });
|
|
expect(decideControllerChangeReload({ hadController: true, refreshing: true })).toEqual({
|
|
shouldReload: false,
|
|
nextRefreshing: true,
|
|
});
|
|
});
|
|
|
|
it("oracle: certificate errors are ignorable, others are not", () => {
|
|
expect(isIgnorableServiceWorkerRegistrationError({ name: "SecurityError", message: "x" })).toBe(true);
|
|
expect(
|
|
isIgnorableServiceWorkerRegistrationError({ name: "TypeError", message: "SSL certificate problem" })
|
|
).toBe(true);
|
|
expect(isIgnorableServiceWorkerRegistrationError({ name: "TypeError", message: "network down" })).toBe(false);
|
|
});
|
|
|
|
it("oracle: register options force updateViaCache none", () => {
|
|
expect(serviceWorkerRegisterOptions()).toEqual({ updateViaCache: "none" });
|
|
});
|
|
|
|
it("oracle: service worker registers only outside Vite DEV and Electron", () => {
|
|
const cases = [
|
|
{ isDev: false, isElectron: false, expected: true },
|
|
{ isDev: true, isElectron: false, expected: false },
|
|
{ isDev: false, isElectron: true, expected: false },
|
|
{ isDev: true, isElectron: true, expected: false },
|
|
];
|
|
for (const input of cases) {
|
|
expect(shouldRegisterServiceWorker(input), JSON.stringify(input)).toBe(input.expected);
|
|
}
|
|
});
|
|
|
|
it("unregisters leftover workers when getRegistrations is present", async () => {
|
|
const unregistered = [];
|
|
const serviceWorker = {
|
|
getRegistrations: async () => [
|
|
{
|
|
unregister: async () => {
|
|
unregistered.push("a");
|
|
return true;
|
|
},
|
|
},
|
|
{
|
|
unregister: async () => {
|
|
unregistered.push("b");
|
|
return true;
|
|
},
|
|
},
|
|
],
|
|
};
|
|
await expect(unregisterServiceWorkersIfPresent(serviceWorker)).resolves.toEqual([true, true]);
|
|
expect(unregistered).toEqual(["a", "b"]);
|
|
await expect(unregisterServiceWorkersIfPresent({})).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("swShellRuntime navigation preload", () => {
|
|
function makeFetchEvent(request, preload) {
|
|
let preloadAccessed = false;
|
|
const event = {
|
|
request,
|
|
get preloadResponse() {
|
|
preloadAccessed = true;
|
|
return preload;
|
|
},
|
|
respondWith: vi.fn(),
|
|
waitUntil: vi.fn(),
|
|
};
|
|
return {
|
|
event,
|
|
wasPreloadAccessed() {
|
|
return preloadAccessed;
|
|
},
|
|
};
|
|
}
|
|
|
|
it("oracle: attach preload only for same-origin navigations that resolveFetch handles", () => {
|
|
const cases = [
|
|
{ pathname: "/", method: "GET", mode: "navigate" },
|
|
{ pathname: "/index.html", method: "GET", mode: "navigate" },
|
|
{ pathname: "/map", method: "GET", mode: "navigate" },
|
|
{ pathname: "/api/v1/status", method: "GET", mode: "navigate" },
|
|
{ pathname: "/ws", method: "GET", mode: "navigate" },
|
|
{ pathname: "/assets/app.js", method: "GET", mode: "cors" },
|
|
{ pathname: "/boot-theme.js", method: "GET", mode: "cors" },
|
|
{ pathname: "/", method: "POST", mode: "navigate" },
|
|
];
|
|
for (const input of cases) {
|
|
const url = `${ORIGIN}${input.pathname}`;
|
|
const request = makeRequest(url, { method: input.method, mode: input.mode });
|
|
const expected = oracleExpectedStrategy(input) === "navigation";
|
|
expect(shouldAttachNavigationPreload(request, ORIGIN)).toBe(expected);
|
|
}
|
|
expect(shouldAttachNavigationPreload(makeRequest("https://evil.example/", { mode: "navigate" }), ORIGIN)).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("does not read preloadResponse for API or asset fetches", () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: vi.fn(async () => okResponse("asset")),
|
|
cacheName: cacheNameForBuild("preload"),
|
|
origin: ORIGIN,
|
|
});
|
|
const api = makeFetchEvent(makeRequest(`${ORIGIN}/api/v1/status`));
|
|
expect(handleFetchEvent(api.event, runtime, ORIGIN)).toBe(false);
|
|
expect(api.wasPreloadAccessed()).toBe(false);
|
|
expect(api.event.respondWith).not.toHaveBeenCalled();
|
|
|
|
const asset = makeFetchEvent(makeRequest(`${ORIGIN}/assets/app.js`));
|
|
expect(handleFetchEvent(asset.event, runtime, ORIGIN)).toBe(true);
|
|
expect(asset.wasPreloadAccessed()).toBe(false);
|
|
expect(asset.event.respondWith).toHaveBeenCalled();
|
|
});
|
|
|
|
it("reads preloadResponse only when responding to a navigation", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const preload = Promise.resolve(okResponse("<html>pre</html>", { headers: { "content-type": "text/html" } }));
|
|
const fetchFn = vi.fn();
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: fetchFn,
|
|
cacheName: cacheNameForBuild("nav-preload"),
|
|
origin: ORIGIN,
|
|
});
|
|
const nav = makeFetchEvent(makeRequest(`${ORIGIN}/`, { mode: "navigate" }), preload);
|
|
expect(handleFetchEvent(nav.event, runtime, ORIGIN)).toBe(true);
|
|
expect(nav.wasPreloadAccessed()).toBe(true);
|
|
expect(nav.event.respondWith).toHaveBeenCalled();
|
|
const response = await nav.event.respondWith.mock.calls[0][0];
|
|
expect(await response.text()).toContain("pre");
|
|
expect(fetchFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("cancelled navigation preload falls through to cache without throwing", async () => {
|
|
const caches = new MemoryCacheStorage();
|
|
const cacheName = cacheNameForBuild("cancelled");
|
|
await (
|
|
await caches.open(cacheName)
|
|
).put(SHELL_FALLBACK_URL, okResponse("<html>shell</html>", { headers: { "content-type": "text/html" } }));
|
|
const cancelled = Promise.reject(
|
|
Object.assign(new Error("The service worker navigation preload request was cancelled"), {
|
|
name: "NetworkError",
|
|
})
|
|
);
|
|
void cancelled.catch(() => {});
|
|
const fetchFn = vi.fn(async () => {
|
|
throw new Error("network failed");
|
|
});
|
|
const runtime = createShellRuntime({
|
|
caches,
|
|
fetch: fetchFn,
|
|
cacheName,
|
|
origin: ORIGIN,
|
|
});
|
|
const navReq = makeRequest(`${ORIGIN}/map`, { mode: "navigate" });
|
|
const response = await runtime.networkFirstNavigation({
|
|
request: navReq,
|
|
preloadResponse: cancelled,
|
|
});
|
|
expect(await response.text()).toContain("shell");
|
|
expect(fetchFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it("settlePreloadResponse maps a rejected preload to null", async () => {
|
|
const cancelled = Promise.reject(new Error("cancelled"));
|
|
void cancelled.catch(() => {});
|
|
await expect(settlePreloadResponse(cancelled)).resolves.toBeNull();
|
|
await expect(settlePreloadResponse(null)).resolves.toBeNull();
|
|
const ok = okResponse("ok");
|
|
await expect(settlePreloadResponse(Promise.resolve(ok))).resolves.toBe(ok);
|
|
});
|
|
});
|