web: adopt Cache Components, Partial Prefetching and ensureStatic
web / ci (push) Successful in 2m2s
web / ci (push) Successful in 2m2s
This commit is contained in:
70 files changed
+1285
-316
No files matched your search
+142
-10
@@ -1,12 +1,12 @@
|
||||
// HTTP smoke tests against the built standalone app (see global-setup.ts).
|
||||
// These cover the surfaces most likely to break on a Next.js upgrade:
|
||||
// prerendered and request-rendered routes, 404s, the events API, the image
|
||||
// optimizer, the fetch-cache + tag revalidation round trip, and draft-mode
|
||||
// optimizer, the cache + tag revalidation round trip, and draft-mode
|
||||
// preview.
|
||||
|
||||
import { beforeAll, describe, expect, inject, test } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, inject, test } from "vitest";
|
||||
|
||||
import { SLUGS } from "./fixtures";
|
||||
import { type MockState, SLUGS } from "./fixtures";
|
||||
|
||||
const app = (path: string) => `${inject("appUrl")}${path}`;
|
||||
const mock = (path: string) => `${inject("mockUrl")}${path}`;
|
||||
@@ -16,14 +16,46 @@ async function getPage(path: string): Promise<{ status: number; html: string }>
|
||||
return { status: res.status, html: await res.text() };
|
||||
}
|
||||
|
||||
async function setNewsVersion(version: "v1" | "v2"): Promise<void> {
|
||||
async function setMockState(state: Partial<MockState>): Promise<void> {
|
||||
const res = await fetch(mock("/__mock/state"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newsHeadlineVersion: version }),
|
||||
body: JSON.stringify(state),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const setNewsVersion = (version: "v1" | "v2") =>
|
||||
setMockState({ newsHeadlineVersion: version });
|
||||
|
||||
type CmsRequests = Record<string, number>;
|
||||
|
||||
// GraphQL requests the mock CMS has received, per operation name
|
||||
async function cmsRequests(): Promise<{
|
||||
total: CmsRequests;
|
||||
sinceBuild: CmsRequests;
|
||||
}> {
|
||||
const res = await fetch(mock("/__mock/requests"));
|
||||
return res.json() as Promise<{ total: CmsRequests; sinceBuild: CmsRequests }>;
|
||||
}
|
||||
|
||||
// Nothing expires by time: the server purges its own cache when it starts
|
||||
// and at Oslo midnight (instrumentation.ts).
|
||||
// Must stay the first test: any later purge would also make this pass
|
||||
describe("startup purge", () => {
|
||||
test("pages from the build are rendered again", async () => {
|
||||
// the purge lands just after the server starts listening
|
||||
const deadline = Date.now() + 10_000;
|
||||
let sinceBuild: CmsRequests = {};
|
||||
while (Date.now() < deadline) {
|
||||
await getPage("/");
|
||||
({ sinceBuild } = await cmsRequests());
|
||||
if ((sinceBuild.home ?? 0) > 0) break;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(sinceBuild.home ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages", () => {
|
||||
test.each([
|
||||
["/", "Testkonsert"],
|
||||
@@ -32,7 +64,7 @@ describe("pages", () => {
|
||||
[`/${SLUGS.genericUrl}`, "Praktisk info"],
|
||||
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
|
||||
[`/lokaler/${SLUGS.venue}`, "Storsalen"],
|
||||
// not prerendered (empty generateStaticParams) — rendered on first request
|
||||
// only the latest page of each is prerendered; these are the latest
|
||||
[`/aktuelt/${SLUGS.news}`, "Testartikkel"],
|
||||
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
|
||||
])("%s renders and contains %s", async (path, needle) => {
|
||||
@@ -51,6 +83,23 @@ describe("pages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("event list", () => {
|
||||
// The list is rendered per request inside the prerendered page, so that
|
||||
// the URL's filters are applied on the server (no layout shift)
|
||||
const activeButton = (html: string) =>
|
||||
/data-active="true"[^>]*><span>Vis (liste|kalender)<\/span>/.exec(html)?.[1];
|
||||
|
||||
test("is server-rendered in list mode by default", async () => {
|
||||
const { html } = await getPage("/arrangementer");
|
||||
expect(activeButton(html)).toBe("liste");
|
||||
});
|
||||
|
||||
test("is server-rendered with the mode from the URL", async () => {
|
||||
const { html } = await getPage("/arrangementer?mode=calendar");
|
||||
expect(activeButton(html)).toBe("kalender");
|
||||
});
|
||||
});
|
||||
|
||||
describe("events api", () => {
|
||||
test("rejects a missing view parameter", async () => {
|
||||
const res = await fetch(app("/api/v1/events"));
|
||||
@@ -84,15 +133,18 @@ describe("image optimizer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch cache and tag revalidation", () => {
|
||||
const path = `/aktuelt/${SLUGS.news}`;
|
||||
|
||||
// Runs for a page prerendered at build time and for one rendered on its
|
||||
// first request, which is how most news and event pages are served
|
||||
describe.each([
|
||||
["prerendered", `/aktuelt/${SLUGS.news}`],
|
||||
["rendered on demand", `/aktuelt/${SLUGS.newsOnDemand}`],
|
||||
])("cache and tag revalidation (%s page)", (_kind, path) => {
|
||||
beforeAll(async () => {
|
||||
await setNewsVersion("v1");
|
||||
});
|
||||
|
||||
test("serves cached content until the webhook fires, then updates", async () => {
|
||||
// prime the cache (the route is request-rendered, then cached)
|
||||
// prerendered at build time or rendered now; either way the CMS has v1
|
||||
const first = await getPage(path);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.html).toContain("Testartikkel (v1)");
|
||||
@@ -136,6 +188,86 @@ describe("fetch cache and tag revalidation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Next stamps a cached page when its render finishes, so a page that was
|
||||
// being rendered while a purge came in looks newer than the purge. The
|
||||
// server sends itself a follow-up purge a few seconds after each webhook
|
||||
describe("purge during a render", () => {
|
||||
const path = `/aktuelt/${SLUGS.newsOnDemand}`;
|
||||
const webhook = () =>
|
||||
fetch(app("/api/revalidate"), {
|
||||
method: "POST",
|
||||
headers: { "x-revalidate-secret": "smoke-secret" },
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await setMockState({ newsDelayMs: 0 });
|
||||
});
|
||||
|
||||
test("two edits in quick succession end up showing the second", async () => {
|
||||
// first edit: published and purged, and the CMS is slow to answer
|
||||
await setMockState({ newsHeadlineVersion: "v1", newsDelayMs: 1_500 });
|
||||
expect((await webhook()).status).toBe(200);
|
||||
const visitor = getPage(path);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// second edit lands while the visitor's page is still rendering
|
||||
await setMockState({ newsHeadlineVersion: "v2", newsDelayMs: 0 });
|
||||
expect((await webhook()).status).toBe(200);
|
||||
|
||||
// that visitor gets what the CMS had when it was asked
|
||||
expect((await visitor).html).toContain("Testartikkel (v1)");
|
||||
|
||||
// everyone after must get the second edit once the follow-up has run
|
||||
const deadline = Date.now() + 12_000;
|
||||
let html = "";
|
||||
while (Date.now() < deadline) {
|
||||
({ html } = await getPage(path));
|
||||
if (html.includes("Testartikkel (v2)")) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
expect(html).toContain("Testartikkel (v2)");
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("midnight purge", () => {
|
||||
async function cacheStatus(path: string): Promise<string | null> {
|
||||
const res = await fetch(app(path));
|
||||
await res.text();
|
||||
return res.headers.get("x-nextjs-cache");
|
||||
}
|
||||
|
||||
test("the midnight purge re-renders pages but only refetches date-dependent data", async () => {
|
||||
const purgeToday = () =>
|
||||
fetch(app("/api/revalidate?tag=today"), {
|
||||
method: "POST",
|
||||
headers: { "x-revalidate-secret": "smoke-secret" },
|
||||
});
|
||||
|
||||
// settle: everything rendered and cached by this server process
|
||||
expect((await purgeToday()).status).toBe(200);
|
||||
await getPage("/");
|
||||
await getPage("/kontakt");
|
||||
expect(await cacheStatus("/")).toBe("HIT");
|
||||
expect(await cacheStatus("/kontakt")).toBe("HIT");
|
||||
const before = (await cmsRequests()).total;
|
||||
|
||||
expect((await purgeToday()).status).toBe(200);
|
||||
|
||||
// every page shows today's date somewhere, so every page renders again
|
||||
expect(await cacheStatus("/")).not.toBe("HIT");
|
||||
expect(await cacheStatus("/kontakt")).not.toBe("HIT");
|
||||
expect(await cacheStatus("/")).toBe("HIT");
|
||||
expect(await cacheStatus("/kontakt")).toBe("HIT");
|
||||
|
||||
const after = (await cmsRequests()).total;
|
||||
// the home page asks the CMS for future events again...
|
||||
expect(after.home).toBe(before.home + 1);
|
||||
// ...while content that only changes on edit comes from the cache
|
||||
expect(after.contacts).toBe(before.contacts);
|
||||
expect(after.openingHoursSets).toBe(before.openingHoursSets);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview", () => {
|
||||
test("draft mode round trip renders the preview page", async () => {
|
||||
const enable = await fetch(
|
||||
|
||||
Reference in new issue
Block a user