When the network failed behind the service worker, it answered from its own store. Check for new data then said nothing newer was published when the server was never reached. The worker now marks that copy, and a fresh check treats it as unreachable; ordinary loads still use it offline. The built-in tasks entry linked to a follow-up fix instead of the feature, and the changelog spec's details now match what shipped. Co-Authored-By: Claude Opus 5.5 <[email protected]>
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { fetchFeed, refreshOutcome } from "../src/client/api.ts";
|
|
import { Colophon } from "../src/client/components/Colophon.tsx";
|
|
import type { EventFeed } from "../src/shared/feed.ts";
|
|
|
|
/**
|
|
* "Check for new data": a reload can be served the browser's cached feed for
|
|
* minutes, so the reader gets a way to ask the server directly — and an honest
|
|
* answer about what came back. It fetches what is *published*; it cannot make
|
|
* the sources be read any sooner.
|
|
*/
|
|
|
|
const feed = (over: Partial<EventFeed> = {}): EventFeed => ({
|
|
schemaVersion: 1,
|
|
generatedAt: "2026-10-13T06:00:00.000Z",
|
|
events: [],
|
|
sources: [],
|
|
...over,
|
|
});
|
|
|
|
describe("refreshOutcome", () => {
|
|
test("new events or source health mean updated", () => {
|
|
const before = feed();
|
|
const after = feed({
|
|
sources: [{
|
|
sourceId: "s", game: "genshin", url: "https://example.test",
|
|
lastSuccessAt: "2026-10-13T12:00:00.000Z", lastConfirmedAt: null,
|
|
contentChangedAt: null, eventCount: 0, parsedCount: 0, statesNoEvents: false,
|
|
}],
|
|
});
|
|
expect(refreshOutcome(before, after)).toBe("updated");
|
|
});
|
|
|
|
test("a rebuild that only moved the build stamp is not new data", () => {
|
|
// generatedAt is when the file was built, not when a source was read
|
|
// (AGENTS.md § the page states its own age); a fresh stamp on the same
|
|
// events would claim an update that brought nothing.
|
|
expect(refreshOutcome(feed(), feed({ generatedAt: "2026-10-13T18:00:00.000Z" }))).toBe("same");
|
|
});
|
|
|
|
test("an error is a failure, whatever was on screen", () => {
|
|
expect(refreshOutcome(feed(), new Error("offline"))).toBe("failed");
|
|
});
|
|
});
|
|
|
|
describe("fetchFeed({ fresh })", () => {
|
|
const original = globalThis.fetch;
|
|
afterEach(() => {
|
|
globalThis.fetch = original;
|
|
});
|
|
|
|
test("revalidates past the browser cache only when asked", async () => {
|
|
const modes: (RequestCache | undefined)[] = [];
|
|
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
|
|
modes.push(init?.cache);
|
|
return new Response(JSON.stringify(feed()), { status: 200 });
|
|
}) as typeof fetch;
|
|
(globalThis as { document?: unknown }).document ??= { baseURI: "https://example.test/" };
|
|
await fetchFeed(undefined, { fresh: true });
|
|
await fetchFeed();
|
|
expect(modes).toEqual(["no-cache", undefined]);
|
|
});
|
|
});
|
|
|
|
describe("the footer's check button", () => {
|
|
const NOW = Date.parse("2026-10-13T12:00:00.000Z");
|
|
|
|
test("sits with the data's age when the app can refresh", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon sources={[]} now={NOW} onRefresh={async () => "same"} online />,
|
|
);
|
|
expect(html).toContain("Check for new data");
|
|
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*>Check for new data/);
|
|
});
|
|
|
|
test("is disabled offline, and says why", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon sources={[]} now={NOW} onRefresh={async () => "same"} online={false} />,
|
|
);
|
|
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Check for new data/);
|
|
expect(html).toContain("offline");
|
|
});
|
|
|
|
test("is absent where nothing can refresh", () => {
|
|
expect(renderToStaticMarkup(<Colophon sources={[]} now={NOW} />)).not.toContain(
|
|
"Check for new data",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the age right after a refresh", () => {
|
|
test("a timestamp a moment ahead of the page's clock reads as 0m, not 'ended'", () => {
|
|
// The page's `now` ticks coarsely; a feed fetched this second can carry a
|
|
// newer instant than it.
|
|
const NOW = Date.parse("2026-10-13T12:00:00.000Z");
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[{
|
|
sourceId: "s", game: "genshin", url: "https://example.test",
|
|
lastSuccessAt: "2026-10-13T12:00:20.000Z", lastConfirmedAt: null,
|
|
contentChangedAt: null, eventCount: 0, parsedCount: 0, statesNoEvents: false,
|
|
}]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
expect(html).not.toContain("ended ago");
|
|
});
|
|
});
|
|
|
|
describe("review fix: a stored copy is not an answer from the server", () => {
|
|
const original = globalThis.fetch;
|
|
afterEach(() => {
|
|
globalThis.fetch = original;
|
|
});
|
|
|
|
test("asked fresh, the service worker's fallback copy counts as not reaching the server", async () => {
|
|
globalThis.fetch = (async () =>
|
|
new Response(JSON.stringify(feed()), {
|
|
status: 200,
|
|
headers: { "x-served-from": "sw-cache" },
|
|
})) as unknown as typeof fetch;
|
|
(globalThis as { document?: unknown }).document ??= { baseURI: "https://example.test/" };
|
|
await expect(fetchFeed(undefined, { fresh: true })).rejects.toThrow();
|
|
// An ordinary load still takes it: offline, the stored copy is the point.
|
|
await expect(fetchFeed()).resolves.toBeDefined();
|
|
});
|
|
});
|