mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
Style the offline recovery page with the supplied character, embedded token-based CSS, and accessible reload action. Include the production-build fixture regression fix. Co-Authored-By: Paperclip <noreply@paperclip.ing>
133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { embedServiceWorkerOfflineStyles } from "./vite-sw-build-id";
|
|
|
|
const uiRoot = resolve(fileURLToPath(new URL("../..", import.meta.url)));
|
|
|
|
type FetchListener = (event: {
|
|
request: { method: string; url: string; mode: string };
|
|
respondWith: (response: Promise<Response | undefined> | Response) => void;
|
|
}) => void;
|
|
|
|
function loadServiceWorkerFetchListener(overrides: {
|
|
fetch: () => Promise<Response>;
|
|
cachesMatch: (key: unknown) => Promise<Response | undefined>;
|
|
}): FetchListener {
|
|
const listeners = new Map<string, (event: unknown) => void>();
|
|
const swSelf = {
|
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
|
listeners.set(type, listener);
|
|
},
|
|
skipWaiting: vi.fn(),
|
|
clients: { claim: vi.fn() },
|
|
location: { origin: "https://app.example.com" },
|
|
};
|
|
const caches = {
|
|
match: overrides.cachesMatch,
|
|
open: vi.fn(async () => ({ put: vi.fn() })),
|
|
keys: vi.fn(async () => []),
|
|
delete: vi.fn(async () => true),
|
|
};
|
|
// Offline fallback belongs to a stamped production build. Development
|
|
// leaves Vite requests to the browser instead of intercepting them.
|
|
const code = embedServiceWorkerOfflineStyles(
|
|
readFileSync(resolve(uiRoot, "public/sw.js"), "utf8"),
|
|
readFileSync(resolve(uiRoot, "src/index.css"), "utf8"),
|
|
)
|
|
.replace("__PAPERCLIP_BUILD_ID__", "fixture-production");
|
|
new Function("self", "caches", "fetch", "Response", "URL", code)(
|
|
swSelf,
|
|
caches,
|
|
overrides.fetch,
|
|
Response,
|
|
URL,
|
|
);
|
|
const listener = listeners.get("fetch");
|
|
if (!listener) throw new Error("sw.js registered no fetch listener");
|
|
return listener as FetchListener;
|
|
}
|
|
|
|
async function respondTo(
|
|
listener: FetchListener,
|
|
request: { method: string; url: string; mode: string },
|
|
): Promise<Response | undefined> {
|
|
let captured: Promise<Response | undefined> | Response | undefined;
|
|
listener({
|
|
request,
|
|
respondWith: (response) => {
|
|
captured = response;
|
|
},
|
|
});
|
|
return await captured;
|
|
}
|
|
|
|
describe("sw.js offline fallback", () => {
|
|
it("serves an uncached retry page for a failed navigation with an empty cache", async () => {
|
|
const listener = loadServiceWorkerFetchListener({
|
|
fetch: () => Promise.reject(new TypeError("network down")),
|
|
cachesMatch: async () => undefined,
|
|
});
|
|
|
|
const response = await respondTo(listener, {
|
|
method: "GET",
|
|
url: "https://app.example.com/settings/instance",
|
|
mode: "navigate",
|
|
});
|
|
|
|
// respondWith must always receive a real Response; resolving undefined
|
|
// fails the navigation with "Failed to convert value to 'Response'".
|
|
expect(response).toBeInstanceOf(Response);
|
|
expect(response!.status).toBe(503);
|
|
expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(response!.headers.get("cache-control")).toBe("no-store");
|
|
const body = await response!.text();
|
|
expect(body).toContain("Paperclip is offline");
|
|
expect(body).toContain("Reload page");
|
|
expect(body).toContain("<style>");
|
|
expect(body).toContain(".paperclip-offline");
|
|
expect(body).toContain("<svg");
|
|
expect(body).not.toMatch(/__PAPERCLIP_|<link|\bsrc=/);
|
|
expect(body).not.toContain("<html>app shell</html>");
|
|
});
|
|
|
|
it("does not replay a legacy cached shell for a failed navigation", async () => {
|
|
const shell = new Response("<html>app shell</html>", { status: 200 });
|
|
const listener = loadServiceWorkerFetchListener({
|
|
fetch: () => Promise.reject(new TypeError("network down")),
|
|
cachesMatch: async (key) => (key === "/" ? shell : undefined),
|
|
});
|
|
|
|
const response = await respondTo(listener, {
|
|
method: "GET",
|
|
url: "https://app.example.com/settings/instance",
|
|
mode: "navigate",
|
|
});
|
|
|
|
expect(response!.status).toBe(503);
|
|
expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(response!.headers.get("cache-control")).toBe("no-store");
|
|
const body = await response!.text();
|
|
expect(body).toContain("Paperclip is offline");
|
|
expect(body).toContain("Reload page");
|
|
expect(body).not.toContain("<html>app shell</html>");
|
|
});
|
|
|
|
it("returns a network-error Response for a failed asset with no cache entry", async () => {
|
|
const listener = loadServiceWorkerFetchListener({
|
|
fetch: () => Promise.reject(new TypeError("network down")),
|
|
cachesMatch: async () => undefined,
|
|
});
|
|
|
|
const response = await respondTo(listener, {
|
|
method: "GET",
|
|
url: "https://app.example.com/assets/index-abc.js",
|
|
mode: "no-cors",
|
|
});
|
|
|
|
expect(response).toBeInstanceOf(Response);
|
|
expect(response!.type).toBe("error");
|
|
});
|
|
});
|