mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 17:22:06 +03:00
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Vitest runs with the webui/ directory as the working directory.
|
|
const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8");
|
|
|
|
const ORIGIN = "https://nanobot.test";
|
|
const CACHE_NAME = "nanobot-static-v2";
|
|
|
|
/** Minimal Cache-compatible in-memory store with SW-style URL normalization. */
|
|
class FakeCacheStore {
|
|
entries = new Map<string, Response>();
|
|
|
|
private key(input: Request | string): string {
|
|
return new URL(typeof input === "string" ? input : input.url, ORIGIN).href;
|
|
}
|
|
|
|
async addAll(urls: string[]): Promise<void> {
|
|
for (const url of urls) {
|
|
this.entries.set(this.key(url), new Response("ok"));
|
|
}
|
|
}
|
|
|
|
async match(input: Request | string): Promise<Response | undefined> {
|
|
return this.entries.get(this.key(input));
|
|
}
|
|
|
|
async put(input: Request | string, response: Response): Promise<void> {
|
|
this.entries.set(this.key(input), response);
|
|
}
|
|
|
|
async delete(input: Request | string): Promise<boolean> {
|
|
return this.entries.delete(this.key(input));
|
|
}
|
|
|
|
async keys(): Promise<Request[]> {
|
|
return [...this.entries.keys()].map((url) => new Request(url));
|
|
}
|
|
}
|
|
|
|
interface LoadedSw {
|
|
store: FakeCacheStore;
|
|
deletedCacheNames: string[];
|
|
fetchMock: ReturnType<typeof vi.fn>;
|
|
skipWaitingMock: ReturnType<typeof vi.fn>;
|
|
claimMock: ReturnType<typeof vi.fn>;
|
|
fire: (type: string, event?: Record<string, unknown>) => Promise<void>;
|
|
}
|
|
|
|
function loadSw(): LoadedSw {
|
|
const listeners = new Map<string, Array<(event: Record<string, unknown>) => void>>();
|
|
const skipWaitingMock = vi.fn();
|
|
const claimMock = vi.fn();
|
|
const self = {
|
|
location: { origin: ORIGIN },
|
|
skipWaiting: skipWaitingMock,
|
|
clients: { claim: claimMock },
|
|
addEventListener: (type: string, cb: (event: Record<string, unknown>) => void) => {
|
|
listeners.set(type, [...(listeners.get(type) ?? []), cb]);
|
|
},
|
|
};
|
|
const store = new FakeCacheStore();
|
|
const deletedCacheNames: string[] = [];
|
|
const caches = {
|
|
open: vi.fn(async () => store),
|
|
match: vi.fn((input: Request | string) => store.match(input)),
|
|
keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v1", "other-app-cache"]),
|
|
delete: vi.fn(async (name: string) => {
|
|
deletedCacheNames.push(name);
|
|
return true;
|
|
}),
|
|
};
|
|
const fetchMock = vi.fn();
|
|
// Execute the plain public/sw.js script inside a controlled scope so the
|
|
// service worker globals (self/caches/fetch) are fully mocked.
|
|
new Function("self", "caches", "fetch", SW_SCRIPT)(self, caches, fetchMock);
|
|
|
|
const fire = async (type: string, event: Record<string, unknown> = {}) => {
|
|
for (const cb of listeners.get(type) ?? []) {
|
|
let waitPromise: Promise<unknown> = Promise.resolve();
|
|
const wrapped = {
|
|
...event,
|
|
waitUntil: (promise: Promise<unknown>) => {
|
|
waitPromise = promise;
|
|
},
|
|
};
|
|
await cb(wrapped);
|
|
await waitPromise;
|
|
}
|
|
};
|
|
|
|
return { store, deletedCacheNames, fetchMock, skipWaitingMock, claimMock, fire };
|
|
}
|
|
|
|
function indexHtml(assetPaths: string[]): Response {
|
|
const refs = assetPaths
|
|
.map(
|
|
(path) =>
|
|
`<script type="module" crossorigin src="${path}"></script><link rel="stylesheet" crossorigin href="${path}">`,
|
|
)
|
|
.join("");
|
|
return new Response(`<!doctype html><html><head>${refs}</head></html>`);
|
|
}
|
|
|
|
function fetchEvent(url: string) {
|
|
const respondWith = vi.fn();
|
|
const event = {
|
|
request: new Request(url, { method: "GET" }),
|
|
respondWith,
|
|
};
|
|
return { event, respondWith };
|
|
}
|
|
|
|
describe("service worker", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("precaches the shell and manifest on install", async () => {
|
|
const sw = loadSw();
|
|
|
|
await sw.fire("install");
|
|
|
|
expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1);
|
|
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true);
|
|
});
|
|
|
|
it("removes stale cache names and prunes unreferenced static entries on activate", async () => {
|
|
const sw = loadSw();
|
|
await sw.fire("install");
|
|
// Simulate the latest loaded build: index.html references only v2 assets.
|
|
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js", "/assets/index-v2.css"]));
|
|
await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2"));
|
|
await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css"));
|
|
await sw.store.put(`${ORIGIN}/assets/lazy-v2.js`, new Response("lazy v2"));
|
|
await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1"));
|
|
await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css"));
|
|
await sw.store.put(`${ORIGIN}/asset-manifest.json`, new Response(JSON.stringify({
|
|
"src/main.tsx": {
|
|
file: "assets/index-v2.js",
|
|
css: ["assets/index-v2.css"],
|
|
},
|
|
"src/lazy.tsx": { file: "assets/lazy-v2.js", isDynamicEntry: true },
|
|
})));
|
|
|
|
await sw.fire("activate");
|
|
|
|
expect(sw.deletedCacheNames).toEqual(["nanobot-static-v1"]);
|
|
expect(sw.claimMock).toHaveBeenCalledTimes(1);
|
|
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/lazy-v2.js`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false);
|
|
});
|
|
|
|
it("does not intercept dynamic or unknown endpoint requests", async () => {
|
|
const sw = loadSw();
|
|
const paths = [
|
|
"/api/v1/models",
|
|
"/auth/session",
|
|
"/__nanobot/ws",
|
|
"/__nanobot/socket.io",
|
|
"/api/chat/completions",
|
|
"/webui/bootstrap",
|
|
"/webui/session/list",
|
|
"/custom-token",
|
|
"/mcp-oauth/callback",
|
|
];
|
|
|
|
for (const path of paths) {
|
|
const { respondWith } = fetchEvent(`${ORIGIN}${path}`);
|
|
await sw.fire("fetch", { request: new Request(`${ORIGIN}${path}`) });
|
|
expect(respondWith, path).not.toHaveBeenCalled();
|
|
}
|
|
expect(sw.fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not intercept non-GET or cross-origin requests", async () => {
|
|
const sw = loadSw();
|
|
|
|
const { respondWith: post } = fetchEvent(`${ORIGIN}/api/v1/models`);
|
|
await sw.fire("fetch", {
|
|
request: new Request(`${ORIGIN}/api/v1/models`, { method: "POST" }),
|
|
});
|
|
expect(post).not.toHaveBeenCalled();
|
|
|
|
const { respondWith: cross } = fetchEvent("https://other.test/assets/app.js");
|
|
await sw.fire("fetch", {
|
|
request: new Request("https://other.test/assets/app.js"),
|
|
});
|
|
expect(cross).not.toHaveBeenCalled();
|
|
expect(sw.fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serves cached static assets without touching the network", async () => {
|
|
const sw = loadSw();
|
|
const assetUrl = `${ORIGIN}/assets/index-a1b2c3.js`;
|
|
await sw.store.put(assetUrl, new Response("cached js"));
|
|
|
|
const { event, respondWith } = fetchEvent(assetUrl);
|
|
await sw.fire("fetch", event);
|
|
|
|
const response = (await respondWith.mock.calls[0][0]) as Response;
|
|
expect(response).toBeDefined();
|
|
expect(await response.text()).toBe("cached js");
|
|
expect(sw.fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches static assets on miss and caches the response, passing the original request", async () => {
|
|
const sw = loadSw();
|
|
const assetUrl = `${ORIGIN}/assets/index-x9y8z7.js`;
|
|
const originalRequest = new Request(assetUrl);
|
|
sw.fetchMock.mockResolvedValue(new Response("fresh js"));
|
|
|
|
const event = { request: originalRequest, respondWith: vi.fn() };
|
|
await sw.fire("fetch", event);
|
|
|
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
|
expect(await response.text()).toBe("fresh js");
|
|
// The exact request object is handed to fetch() — credentials mode and
|
|
// other properties are preserved, never reconstructed.
|
|
expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest);
|
|
expect(sw.store.entries.has(assetUrl)).toBe(true);
|
|
});
|
|
|
|
it.each(["private", "no-cache", "no-store"])(
|
|
"does not cache a %s response even under the static asset prefix",
|
|
async (cacheControl) => {
|
|
const sw = loadSw();
|
|
const tokenUrl = `${ORIGIN}/assets/custom-token`;
|
|
const originalRequest = new Request(tokenUrl);
|
|
sw.fetchMock.mockResolvedValue(
|
|
new Response('{"token":"short-lived"}', {
|
|
headers: {
|
|
"Cache-Control": cacheControl,
|
|
"Content-Type": "application/json",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const event = { request: originalRequest, respondWith: vi.fn() };
|
|
await sw.fire("fetch", event);
|
|
|
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
|
expect(await response.json()).toEqual({ token: "short-lived" });
|
|
expect(sw.store.entries.has(tokenUrl)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("keeps un-hashed brand assets on the network-first path", async () => {
|
|
const sw = loadSw();
|
|
const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`;
|
|
const originalRequest = new Request(iconUrl);
|
|
sw.fetchMock.mockResolvedValue(new Response("png bytes"));
|
|
|
|
const event = { request: originalRequest, respondWith: vi.fn() };
|
|
await sw.fire("fetch", event);
|
|
|
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
|
expect(await response.text()).toBe("png bytes");
|
|
// Unlike hashed /assets/ files, brand icons are fetched every load so a
|
|
// future icon swap reaches installed clients.
|
|
expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest);
|
|
expect(sw.store.entries.has(iconUrl)).toBe(true);
|
|
});
|
|
|
|
it("clones network responses before yielding their body to the browser", async () => {
|
|
const sw = loadSw();
|
|
const request = new Request(`${ORIGIN}/brand/nanobot_icon_192.png`);
|
|
let browserOwnsBody = false;
|
|
let clonedBeforeBrowser = false;
|
|
const response = {
|
|
ok: true,
|
|
clone: vi.fn(() => {
|
|
clonedBeforeBrowser = !browserOwnsBody;
|
|
return new Response("cached png bytes");
|
|
}),
|
|
} as unknown as Response;
|
|
sw.fetchMock.mockResolvedValue(response);
|
|
const respondWith = vi.fn((pending: Promise<Response>) => {
|
|
void pending.then(() => {
|
|
browserOwnsBody = true;
|
|
});
|
|
});
|
|
|
|
await sw.fire("fetch", { request, respondWith });
|
|
|
|
expect(clonedBeforeBrowser).toBe(true);
|
|
expect(response.clone).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => {
|
|
const sw = loadSw();
|
|
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"]));
|
|
// Assets from an older build, still referenced by the cached v2 shell.
|
|
await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2"));
|
|
await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1"));
|
|
|
|
// Offline: network rejects, cached shell is returned.
|
|
sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
|
const offlineEvent = {
|
|
request: new Request(`${ORIGIN}/`),
|
|
respondWith: vi.fn(),
|
|
};
|
|
await sw.fire("fetch", offlineEvent);
|
|
const offlineResponse = (await offlineEvent.respondWith.mock.calls[0][0]) as Response;
|
|
expect(await offlineResponse.text()).toContain("assets/index-v2.js");
|
|
|
|
// Online: network response wins, refreshes the cached shell, and prunes
|
|
// entries the new index.html no longer references.
|
|
const freshShell = indexHtml(["/assets/index-v3.js"]);
|
|
sw.fetchMock.mockImplementation((input: Request | string) => {
|
|
const url = new URL(typeof input === "string" ? input : input.url, ORIGIN);
|
|
if (url.pathname === "/asset-manifest.json") {
|
|
return Promise.resolve(new Response(JSON.stringify({
|
|
"src/main.tsx": { file: "assets/index-v3.js" },
|
|
})));
|
|
}
|
|
return Promise.resolve(freshShell.clone());
|
|
});
|
|
const onlineEvent = {
|
|
request: new Request(`${ORIGIN}/`),
|
|
respondWith: vi.fn(),
|
|
};
|
|
await sw.fire("fetch", onlineEvent);
|
|
const onlineResponse = (await onlineEvent.respondWith.mock.calls[0][0]) as Response;
|
|
expect(await onlineResponse.text()).toContain("assets/index-v3.js");
|
|
// The prune is fire-and-forget; give its microtasks a chance to settle.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false);
|
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(false);
|
|
});
|
|
|
|
it("serves the cached app shell for offline deep-link navigations", async () => {
|
|
const sw = loadSw();
|
|
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"]));
|
|
sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
|
|
|
// happy-dom's Request ignores `mode: "navigate"` in the constructor;
|
|
// force it so the service worker treats this as a navigation.
|
|
const request = new Request(`${ORIGIN}/settings`);
|
|
Object.defineProperty(request, "mode", { value: "navigate" });
|
|
const event = { request, respondWith: vi.fn() };
|
|
await sw.fire("fetch", event);
|
|
|
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
|
expect(await response.text()).toContain("assets/index-v2.js");
|
|
});
|
|
});
|