web: smoke-test the built standalone app against a mock graphql cms
This commit is contained in:
@@ -26,4 +26,6 @@ jobs:
|
|||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx tsc --noEmit
|
- run: npx tsc --noEmit
|
||||||
|
- run: npm run lint
|
||||||
- run: npm test
|
- run: npm test
|
||||||
|
- run: npm run test:smoke
|
||||||
|
|||||||
+5
-1
@@ -18,7 +18,11 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
minimumCacheTTL: 7 * 24 * 60 * 60, // 7 days
|
minimumCacheTTL: 7 * 24 * 60 * 60, // 7 days
|
||||||
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
|
// smoke tests build in production mode against a mock CMS on 127.0.0.1;
|
||||||
|
// the flag is serialized into the build, so it must be set at build time
|
||||||
|
dangerouslyAllowLocalIP:
|
||||||
|
process.env.NODE_ENV === "development" ||
|
||||||
|
process.env.SMOKE_ALLOW_LOCAL_IMAGES === "1",
|
||||||
},
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:smoke": "vitest run --config vitest.smoke.config.mts",
|
||||||
"codegen": "graphql-codegen",
|
"codegen": "graphql-codegen",
|
||||||
"perf:build": "next build",
|
"perf:build": "next build",
|
||||||
"perf:serve": "next start -p 3100",
|
"perf:serve": "next start -p 3100",
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
// Canned GraphQL responses for the smoke-test mock CMS.
|
||||||
|
//
|
||||||
|
// Every piece is typed with `satisfies` against the *fragment* types generated
|
||||||
|
// by codegen (fragment masking makes the query types too weak — their
|
||||||
|
// `$fragmentRefs` props are optional). When `npm run codegen` regenerates
|
||||||
|
// after a schema change, `tsc --noEmit` fails here, flagging stale fixtures.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AllAssociationSlugsQuery,
|
||||||
|
AllAssociationsQuery,
|
||||||
|
AllGenericSlugsQuery,
|
||||||
|
AllVenueSlugsQuery,
|
||||||
|
AssociationBySlugQuery,
|
||||||
|
AssociationFragment,
|
||||||
|
AssociationIndexFragment,
|
||||||
|
ContactIndexFragment,
|
||||||
|
ContactsQuery,
|
||||||
|
EventBySlugQuery,
|
||||||
|
EventCategoryFragment,
|
||||||
|
EventFragment,
|
||||||
|
EventIndexFragment,
|
||||||
|
EventIndexMetadataQuery,
|
||||||
|
EventListItemFragment,
|
||||||
|
EventOrganizerFragment,
|
||||||
|
EventOverviewItemFragment,
|
||||||
|
FutureEventsQuery,
|
||||||
|
GenericFragment,
|
||||||
|
GenericPageByUrlQuery,
|
||||||
|
HomeFragment,
|
||||||
|
HomeQuery,
|
||||||
|
ImageFragment,
|
||||||
|
NewsBySlugQuery,
|
||||||
|
NewsFragment,
|
||||||
|
NewsIndexFragment,
|
||||||
|
NewsIndexMetadataQuery,
|
||||||
|
NewsListItemFragment,
|
||||||
|
NewsQuery,
|
||||||
|
OpeningHoursSetFragment,
|
||||||
|
OpeningHoursSetsQuery,
|
||||||
|
PreviewPageQuery,
|
||||||
|
SearchQuery,
|
||||||
|
SponsorsPageFragment,
|
||||||
|
SponsorsQuery,
|
||||||
|
StudioFragment,
|
||||||
|
StudioQuery,
|
||||||
|
VenueBySlugQuery,
|
||||||
|
VenueFragment,
|
||||||
|
VenueIndexFragment,
|
||||||
|
VenueIndexQuery,
|
||||||
|
VenueRentalIndexFragment,
|
||||||
|
VenueRentalIndexQuery,
|
||||||
|
} from "@/gql/graphql";
|
||||||
|
|
||||||
|
export type MockState = {
|
||||||
|
newsHeadlineVersion: "v1" | "v2";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SLUGS = {
|
||||||
|
genericUrl: "praktisk", // served at /praktisk, wagtail urlPath /home/praktisk/
|
||||||
|
association: "testforening",
|
||||||
|
venue: "storsalen",
|
||||||
|
news: "test-artikkel",
|
||||||
|
event: "testkonsert",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function newsTitle(state: MockState): string {
|
||||||
|
return `Testartikkel (${state.newsHeadlineVersion})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resolver = (vars: Record<string, unknown>, state: MockState) => unknown;
|
||||||
|
export type Fixtures = Record<string, Resolver>;
|
||||||
|
|
||||||
|
// Fragment masking types a fragment spread's slot as an opaque ref, but the
|
||||||
|
// wire format is simply the fragment's fields. Each piece above proves its
|
||||||
|
// shape with `satisfies <Fragment>`; this cast lets it slot into the masked
|
||||||
|
// position of the enclosing query/fragment type.
|
||||||
|
function mask<T>(value: T): { " $fragmentRefs"?: undefined } {
|
||||||
|
return value as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFixtures(cmsBaseUrl: string): Fixtures {
|
||||||
|
const inAWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const occurrenceStart = inAWeek.toISOString();
|
||||||
|
const occurrenceEnd = new Date(
|
||||||
|
inAWeek.getTime() + 2 * 60 * 60 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const image = {
|
||||||
|
id: "1",
|
||||||
|
url: `${cmsBaseUrl}/media/pig.png`,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
alt: "En gris",
|
||||||
|
attribution: null,
|
||||||
|
} satisfies ImageFragment;
|
||||||
|
|
||||||
|
const eventCategory = {
|
||||||
|
__typename: "EventCategory",
|
||||||
|
name: "Konsert",
|
||||||
|
slug: "konsert",
|
||||||
|
pig: "PIG_PINK",
|
||||||
|
showInFilters: true,
|
||||||
|
} satisfies EventCategoryFragment;
|
||||||
|
|
||||||
|
const eventOrganizer = {
|
||||||
|
__typename: "EventOrganizer",
|
||||||
|
id: "1",
|
||||||
|
name: "Testforeningen",
|
||||||
|
slug: "testforeningen",
|
||||||
|
externalUrl: null,
|
||||||
|
association: null,
|
||||||
|
} satisfies EventOrganizerFragment;
|
||||||
|
|
||||||
|
const eventListItem = {
|
||||||
|
__typename: "EventPage",
|
||||||
|
id: "10",
|
||||||
|
slug: SLUGS.event,
|
||||||
|
title: "Testkonsert",
|
||||||
|
subtitle: "En kveld med smoke tests",
|
||||||
|
featuredImage: mask(image),
|
||||||
|
occurrences: [
|
||||||
|
{
|
||||||
|
__typename: "EventOccurrence",
|
||||||
|
id: "100",
|
||||||
|
start: occurrenceStart,
|
||||||
|
end: occurrenceEnd,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies EventListItemFragment;
|
||||||
|
|
||||||
|
const venuePageRef = {
|
||||||
|
__typename: "VenuePage" as const,
|
||||||
|
id: "30",
|
||||||
|
slug: SLUGS.venue,
|
||||||
|
title: "Storsalen",
|
||||||
|
preposition: "i",
|
||||||
|
url: `/lokaler/${SLUGS.venue}/`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventOverviewItem = {
|
||||||
|
...eventListItem,
|
||||||
|
categories: [mask(eventCategory)],
|
||||||
|
occurrences: [
|
||||||
|
{
|
||||||
|
__typename: "EventOccurrence",
|
||||||
|
id: "100",
|
||||||
|
start: occurrenceStart,
|
||||||
|
end: occurrenceEnd,
|
||||||
|
venueCustom: null,
|
||||||
|
venue: venuePageRef,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizers: [mask(eventOrganizer)],
|
||||||
|
} satisfies EventOverviewItemFragment;
|
||||||
|
|
||||||
|
const eventPage = {
|
||||||
|
...eventOverviewItem,
|
||||||
|
seoTitle: "Testkonsert",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: "Bli med på testkonsert.",
|
||||||
|
pig: "PIG_PINK",
|
||||||
|
facebookUrl: null,
|
||||||
|
ticketUrl: null,
|
||||||
|
free: true,
|
||||||
|
priceRegular: null,
|
||||||
|
priceMember: null,
|
||||||
|
priceStudent: null,
|
||||||
|
body: [],
|
||||||
|
} satisfies EventFragment;
|
||||||
|
|
||||||
|
const eventIndex = {
|
||||||
|
__typename: "EventIndex",
|
||||||
|
id: "3",
|
||||||
|
slug: "arrangementer",
|
||||||
|
seoTitle: "Arrangementer",
|
||||||
|
searchDescription: null,
|
||||||
|
title: "Arrangementer",
|
||||||
|
} satisfies EventIndexFragment;
|
||||||
|
|
||||||
|
const newsListItem = (state: MockState) =>
|
||||||
|
({
|
||||||
|
__typename: "NewsPage",
|
||||||
|
id: "20",
|
||||||
|
slug: SLUGS.news,
|
||||||
|
title: newsTitle(state),
|
||||||
|
firstPublishedAt: "2026-08-01T12:00:00+02:00",
|
||||||
|
excerpt: "Et utdrag.",
|
||||||
|
featuredImage: mask(image),
|
||||||
|
}) satisfies NewsListItemFragment;
|
||||||
|
|
||||||
|
const newsPage = (state: MockState) =>
|
||||||
|
({
|
||||||
|
...newsListItem(state),
|
||||||
|
seoTitle: newsTitle(state),
|
||||||
|
searchDescription: null,
|
||||||
|
lead: "Ingressen.",
|
||||||
|
body: [],
|
||||||
|
}) satisfies NewsFragment;
|
||||||
|
|
||||||
|
const newsIndex = {
|
||||||
|
__typename: "NewsIndex",
|
||||||
|
id: "2",
|
||||||
|
slug: "aktuelt",
|
||||||
|
seoTitle: "Aktuelt",
|
||||||
|
searchDescription: null,
|
||||||
|
title: "Aktuelt",
|
||||||
|
lead: null,
|
||||||
|
} satisfies NewsIndexFragment;
|
||||||
|
|
||||||
|
const home = {
|
||||||
|
__typename: "HomePage",
|
||||||
|
featuredEvents: [],
|
||||||
|
} satisfies HomeFragment;
|
||||||
|
|
||||||
|
const genericPage = {
|
||||||
|
__typename: "GenericPage",
|
||||||
|
id: "40",
|
||||||
|
urlPath: `/home/${SLUGS.genericUrl}/`,
|
||||||
|
seoTitle: "Praktisk info",
|
||||||
|
searchDescription: null,
|
||||||
|
title: "Praktisk info",
|
||||||
|
lead: "Alt du trenger å vite.",
|
||||||
|
pig: "PIG_PINK",
|
||||||
|
body: [],
|
||||||
|
} satisfies GenericFragment;
|
||||||
|
|
||||||
|
const association = {
|
||||||
|
__typename: "AssociationPage",
|
||||||
|
id: "50",
|
||||||
|
slug: SLUGS.association,
|
||||||
|
title: "Testforeningen",
|
||||||
|
seoTitle: "Testforeningen",
|
||||||
|
searchDescription: null,
|
||||||
|
excerpt: "En forening for testing.",
|
||||||
|
lead: null,
|
||||||
|
associationType: "Kultur",
|
||||||
|
websiteUrl: null,
|
||||||
|
body: [],
|
||||||
|
logo: null,
|
||||||
|
} satisfies AssociationFragment;
|
||||||
|
|
||||||
|
const associationIndex = {
|
||||||
|
__typename: "AssociationIndex",
|
||||||
|
title: "Foreninger",
|
||||||
|
seoTitle: "Foreninger",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: null,
|
||||||
|
body: [],
|
||||||
|
} satisfies AssociationIndexFragment;
|
||||||
|
|
||||||
|
const venue = {
|
||||||
|
__typename: "VenuePage",
|
||||||
|
id: "30",
|
||||||
|
slug: SLUGS.venue,
|
||||||
|
title: "Storsalen",
|
||||||
|
seoTitle: "Storsalen",
|
||||||
|
searchDescription: null,
|
||||||
|
showAsBookable: true,
|
||||||
|
showInOverview: true,
|
||||||
|
floor: "2",
|
||||||
|
preposition: "i",
|
||||||
|
usedFor: "Konserter",
|
||||||
|
techSpecsUrl: null,
|
||||||
|
capabilityAudio: null,
|
||||||
|
capabilityAudioVideo: null,
|
||||||
|
capabilityBar: null,
|
||||||
|
capabilityLighting: null,
|
||||||
|
capacityLegal: "450",
|
||||||
|
capacityStanding: "450",
|
||||||
|
capacitySitting: "200",
|
||||||
|
images: [],
|
||||||
|
body: [],
|
||||||
|
featuredImage: null,
|
||||||
|
} satisfies VenueFragment;
|
||||||
|
|
||||||
|
const venueIndex = {
|
||||||
|
__typename: "VenueIndex",
|
||||||
|
title: "Lokaler",
|
||||||
|
seoTitle: "Lokaler",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: null,
|
||||||
|
body: [],
|
||||||
|
} satisfies VenueIndexFragment;
|
||||||
|
|
||||||
|
const venueRentalIndex = {
|
||||||
|
__typename: "VenueRentalIndex",
|
||||||
|
title: "Utleie",
|
||||||
|
seoTitle: "Utleie",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: null,
|
||||||
|
body: [],
|
||||||
|
} satisfies VenueRentalIndexFragment;
|
||||||
|
|
||||||
|
const contactIndex = {
|
||||||
|
__typename: "ContactIndex",
|
||||||
|
title: "Kontakt",
|
||||||
|
seoTitle: "Kontakt",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: null,
|
||||||
|
body: [],
|
||||||
|
} satisfies ContactIndexFragment;
|
||||||
|
|
||||||
|
const sponsorsPage = {
|
||||||
|
__typename: "SponsorsPage",
|
||||||
|
title: "Sponsorer",
|
||||||
|
seoTitle: "Sponsorer",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: null,
|
||||||
|
body: [],
|
||||||
|
sponsors: [],
|
||||||
|
} satisfies SponsorsPageFragment;
|
||||||
|
|
||||||
|
const studioPage = {
|
||||||
|
__typename: "StudioPage",
|
||||||
|
id: "60",
|
||||||
|
title: "Studio",
|
||||||
|
seoTitle: "Studio",
|
||||||
|
searchDescription: null,
|
||||||
|
lead: null,
|
||||||
|
pig: "PIG_PINK",
|
||||||
|
logo: null,
|
||||||
|
body: [],
|
||||||
|
} satisfies StudioFragment;
|
||||||
|
|
||||||
|
const openingHoursSet = {
|
||||||
|
name: "Vanlige åpningstider",
|
||||||
|
effectiveFrom: "2026-01-01",
|
||||||
|
effectiveTo: null,
|
||||||
|
announcement: null,
|
||||||
|
items: [],
|
||||||
|
} satisfies OpeningHoursSetFragment;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// --- build time: generateStaticParams ---
|
||||||
|
allGenericSlugs: () =>
|
||||||
|
({
|
||||||
|
pages: [{ id: "40", urlPath: `/home/${SLUGS.genericUrl}/` }],
|
||||||
|
}) satisfies AllGenericSlugsQuery,
|
||||||
|
allAssociationSlugs: () =>
|
||||||
|
({
|
||||||
|
pages: [{ id: "50", slug: SLUGS.association }],
|
||||||
|
}) satisfies AllAssociationSlugsQuery,
|
||||||
|
allVenueSlugs: () =>
|
||||||
|
({
|
||||||
|
pages: [{ id: "30", slug: SLUGS.venue }],
|
||||||
|
}) satisfies AllVenueSlugsQuery,
|
||||||
|
|
||||||
|
// --- build time: static pages ---
|
||||||
|
home: (_vars, state) =>
|
||||||
|
({
|
||||||
|
events: { futureEvents: [mask(eventListItem)] },
|
||||||
|
home: mask(home),
|
||||||
|
news: [mask(newsListItem(state))],
|
||||||
|
}) satisfies HomeQuery,
|
||||||
|
news: (_vars, state) =>
|
||||||
|
({
|
||||||
|
index: mask(newsIndex),
|
||||||
|
news: [mask(newsListItem(state))],
|
||||||
|
}) satisfies NewsQuery,
|
||||||
|
newsIndexMetadata: () =>
|
||||||
|
({ index: mask(newsIndex) }) satisfies NewsIndexMetadataQuery,
|
||||||
|
futureEvents: () =>
|
||||||
|
({
|
||||||
|
index: mask(eventIndex),
|
||||||
|
events: { futureEvents: [mask(eventOverviewItem)] },
|
||||||
|
eventCategories: [mask(eventCategory)],
|
||||||
|
eventOrganizers: [mask(eventOrganizer)],
|
||||||
|
venues: [
|
||||||
|
{
|
||||||
|
id: "30",
|
||||||
|
title: "Storsalen",
|
||||||
|
slug: SLUGS.venue,
|
||||||
|
preposition: "i",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) satisfies FutureEventsQuery,
|
||||||
|
eventIndexMetadata: () =>
|
||||||
|
({ index: mask(eventIndex) }) satisfies EventIndexMetadataQuery,
|
||||||
|
allAssociations: () =>
|
||||||
|
({
|
||||||
|
index: mask(associationIndex),
|
||||||
|
associations: [mask(association)],
|
||||||
|
}) satisfies AllAssociationsQuery,
|
||||||
|
contacts: () => ({ index: mask(contactIndex) }) satisfies ContactsQuery,
|
||||||
|
venueIndex: () =>
|
||||||
|
({ index: mask(venueIndex), venues: [mask(venue)] }) satisfies VenueIndexQuery,
|
||||||
|
venueRentalIndex: () =>
|
||||||
|
({
|
||||||
|
index: mask(venueRentalIndex),
|
||||||
|
venues: [mask(venue)],
|
||||||
|
}) satisfies VenueRentalIndexQuery,
|
||||||
|
sponsors: () => ({ page: mask(sponsorsPage) }) satisfies SponsorsQuery,
|
||||||
|
studio: () => ({ page: mask(studioPage) }) satisfies StudioQuery,
|
||||||
|
openingHoursSets: () =>
|
||||||
|
({ openingHoursSets: [mask(openingHoursSet)] }) satisfies OpeningHoursSetsQuery,
|
||||||
|
|
||||||
|
// --- build time: prerendered dynamic pages (null for unknown slugs → notFound) ---
|
||||||
|
genericPageByUrl: (vars) =>
|
||||||
|
({
|
||||||
|
page:
|
||||||
|
vars.urlPath === `/home/${SLUGS.genericUrl}/` ? mask(genericPage) : null,
|
||||||
|
}) satisfies GenericPageByUrlQuery,
|
||||||
|
associationBySlug: (vars) =>
|
||||||
|
({
|
||||||
|
association: vars.slug === SLUGS.association ? mask(association) : null,
|
||||||
|
}) satisfies AssociationBySlugQuery,
|
||||||
|
venueBySlug: (vars) =>
|
||||||
|
({
|
||||||
|
venue: vars.slug === SLUGS.venue ? mask(venue) : null,
|
||||||
|
}) satisfies VenueBySlugQuery,
|
||||||
|
|
||||||
|
// --- runtime ---
|
||||||
|
newsBySlug: (vars, state) =>
|
||||||
|
({
|
||||||
|
news: vars.slug === SLUGS.news ? mask(newsPage(state)) : null,
|
||||||
|
}) satisfies NewsBySlugQuery,
|
||||||
|
eventBySlug: (vars) =>
|
||||||
|
({
|
||||||
|
event: vars.slug === SLUGS.event ? mask(eventPage) : null,
|
||||||
|
}) satisfies EventBySlugQuery,
|
||||||
|
search: (vars) =>
|
||||||
|
({
|
||||||
|
results:
|
||||||
|
typeof vars.query === "string" && vars.query.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
__typename: "GenericPage",
|
||||||
|
id: "40",
|
||||||
|
title: "Praktisk info",
|
||||||
|
url: `/${SLUGS.genericUrl}/`,
|
||||||
|
lead: "Alt du trenger å vite.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}) satisfies SearchQuery,
|
||||||
|
previewPage: (_vars, state) =>
|
||||||
|
({
|
||||||
|
page: { __typename: "NewsPage" as const, ...mask(newsPage(state)) },
|
||||||
|
}) satisfies PreviewPageQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Builds the app against the mock CMS and serves the standalone output,
|
||||||
|
// reproducing the Dockerfile's layout (server.js + .next/static + public).
|
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { cpSync, existsSync, rmSync } from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { TestProject } from "vitest/node";
|
||||||
|
|
||||||
|
import { startMockCms } from "./mock-cms";
|
||||||
|
|
||||||
|
const WEB = fileURLToPath(new URL("../..", import.meta.url));
|
||||||
|
|
||||||
|
declare module "vitest" {
|
||||||
|
interface ProvidedContext {
|
||||||
|
appUrl: string;
|
||||||
|
mockUrl: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFreePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = http.createServer();
|
||||||
|
srv.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = srv.address() as AddressInfo;
|
||||||
|
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForOk(url: string, timeoutMs: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) return;
|
||||||
|
lastError = new Error(`GET ${url} -> ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`timed out waiting for ${url}: ${lastError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function setup(project: TestProject) {
|
||||||
|
const mock = await startMockCms();
|
||||||
|
const appPort = await getFreePort();
|
||||||
|
const appUrl = `http://127.0.0.1:${appPort}`;
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
// process env beats web/.env, steering the build away from cms.neuf.no
|
||||||
|
WAGTAIL_BASE_URL: mock.url,
|
||||||
|
// production builds refuse local-IP image sources without this override
|
||||||
|
SMOKE_ALLOW_LOCAL_IMAGES: "1",
|
||||||
|
REVALIDATE_WEBHOOK_SECRET: "smoke-secret",
|
||||||
|
URL: appUrl,
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
rmSync(join(WEB, ".next", "standalone"), { recursive: true, force: true });
|
||||||
|
// async spawn: the mock CMS lives in this process, so the event loop must
|
||||||
|
// stay free to answer the build's GraphQL requests
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const build = spawn("node", ["node_modules/next/dist/bin/next", "build"], {
|
||||||
|
cwd: WEB,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
build.on("error", reject);
|
||||||
|
build.on("exit", (code) =>
|
||||||
|
code === 0
|
||||||
|
? resolve()
|
||||||
|
: reject(new Error(`next build exited with code ${code}`))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mock.graphqlRequests === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirror the Dockerfile: standalone lacks static assets and public/
|
||||||
|
const standalone = join(WEB, ".next", "standalone");
|
||||||
|
if (!existsSync(join(standalone, "server.js"))) {
|
||||||
|
throw new Error(`no server.js in ${standalone} — standalone layout changed?`);
|
||||||
|
}
|
||||||
|
cpSync(join(WEB, ".next", "static"), join(standalone, ".next", "static"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
cpSync(join(WEB, "public"), join(standalone, "public"), { recursive: true });
|
||||||
|
|
||||||
|
const app: ChildProcess = spawn("node", ["server.js"], {
|
||||||
|
cwd: standalone,
|
||||||
|
env: { ...env, PORT: String(appPort), HOSTNAME: "127.0.0.1" },
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForOk(`${appUrl}/`, 60_000);
|
||||||
|
|
||||||
|
project.provide("appUrl", appUrl);
|
||||||
|
project.provide("mockUrl", mock.url);
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
app.kill();
|
||||||
|
await mock.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// A tiny stand-in for the Wagtail CMS, reachable over HTTP so both
|
||||||
|
// `next build` and the standalone server (separate child processes) can
|
||||||
|
// query it. Serves the canned GraphQL responses from fixtures.ts, a 1x1 PNG
|
||||||
|
// for image URLs, and a state endpoint the revalidation test uses to
|
||||||
|
// "publish a change" in the CMS.
|
||||||
|
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
|
||||||
|
import { makeFixtures, type Fixtures, type MockState } from "./fixtures";
|
||||||
|
|
||||||
|
// 1x1 transparent PNG
|
||||||
|
const TINY_PNG = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MockCms = {
|
||||||
|
url: string;
|
||||||
|
state: MockState;
|
||||||
|
graphqlRequests: number;
|
||||||
|
close(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (c) => chunks.push(c));
|
||||||
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startMockCms(): Promise<MockCms> {
|
||||||
|
const state: MockState = { newsHeadlineVersion: "v1" };
|
||||||
|
let fixtures: Fixtures | null = null; // built once we know our own URL
|
||||||
|
|
||||||
|
const mock: MockCms = {
|
||||||
|
url: "",
|
||||||
|
state,
|
||||||
|
graphqlRequests: 0,
|
||||||
|
close: () =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
server.close((err) => (err ? reject(err) : resolve()))
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && req.url?.startsWith("/media/")) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": "image/png",
|
||||||
|
"content-length": TINY_PNG.length,
|
||||||
|
});
|
||||||
|
res.end(TINY_PNG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/__mock/state") {
|
||||||
|
Object.assign(state, JSON.parse(await readBody(req)));
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end("{}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url?.startsWith("/api/graphql")) {
|
||||||
|
mock.graphqlRequests += 1;
|
||||||
|
// urql sends both POST (JSON body) and GET (URL params) requests
|
||||||
|
let body: {
|
||||||
|
query?: string;
|
||||||
|
operationName?: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const params = new URL(req.url, mock.url).searchParams;
|
||||||
|
body = {
|
||||||
|
query: params.get("query") ?? undefined,
|
||||||
|
operationName: params.get("operationName") ?? undefined,
|
||||||
|
variables: JSON.parse(params.get("variables") ?? "{}"),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
body = JSON.parse(await readBody(req));
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
body.operationName ??
|
||||||
|
/(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1];
|
||||||
|
const resolve = name ? fixtures?.[name] : undefined;
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
if (!resolve) {
|
||||||
|
// Loud gap detection: build-time queries throw on GraphQL errors,
|
||||||
|
// so a missing fixture fails `next build` with the name visible.
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
errors: [{ message: `mock-cms: no fixture for operation ${name}` }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.end(JSON.stringify({ data: resolve(body.variables ?? {}, state) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`mock-cms: unhandled ${req.method} ${req.url}`);
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ errors: [{ message: String(err) }] }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve())
|
||||||
|
);
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
mock.url = `http://127.0.0.1:${port}`;
|
||||||
|
fixtures = makeFixtures(mock.url);
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// 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
|
||||||
|
// preview.
|
||||||
|
|
||||||
|
import { beforeAll, describe, expect, inject, test } from "vitest";
|
||||||
|
|
||||||
|
import { SLUGS } from "./fixtures";
|
||||||
|
|
||||||
|
const app = (path: string) => `${inject("appUrl")}${path}`;
|
||||||
|
const mock = (path: string) => `${inject("mockUrl")}${path}`;
|
||||||
|
|
||||||
|
async function getPage(path: string): Promise<{ status: number; html: string }> {
|
||||||
|
const res = await fetch(app(path));
|
||||||
|
return { status: res.status, html: await res.text() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setNewsVersion(version: "v1" | "v2"): Promise<void> {
|
||||||
|
const res = await fetch(mock("/__mock/state"), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ newsHeadlineVersion: version }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pages", () => {
|
||||||
|
test.each([
|
||||||
|
["/", "Testkonsert"],
|
||||||
|
["/aktuelt", "Testartikkel"],
|
||||||
|
["/arrangementer", "Testkonsert"],
|
||||||
|
[`/${SLUGS.genericUrl}`, "Praktisk info"],
|
||||||
|
[`/foreninger/${SLUGS.association}`, "Testforeningen"],
|
||||||
|
[`/lokaler/${SLUGS.venue}`, "Storsalen"],
|
||||||
|
// not prerendered (empty generateStaticParams) — rendered on first request
|
||||||
|
[`/aktuelt/${SLUGS.news}`, "Testartikkel"],
|
||||||
|
[`/arrangementer/${SLUGS.event}`, "Testkonsert"],
|
||||||
|
])("%s renders and contains %s", async (path, needle) => {
|
||||||
|
const { status, html } = await getPage(path);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(html).toContain(needle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([["/aktuelt/finnes-ikke"], ["/tull/og-toys"]])(
|
||||||
|
"%s is a 404",
|
||||||
|
async (path) => {
|
||||||
|
const { status, html } = await getPage(path);
|
||||||
|
expect(status).toBe(404);
|
||||||
|
expect(html).toContain("404");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("events api", () => {
|
||||||
|
test("rejects a missing view parameter", async () => {
|
||||||
|
const res = await fetch(app("/api/v1/events"));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns compact events", async () => {
|
||||||
|
const res = await fetch(app("/api/v1/events?view=compact-app"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
events: Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
nextOccurrence: { start: string } | null;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
expect(body.total).toBe(1);
|
||||||
|
expect(body.events[0].slug).toBe(SLUGS.event);
|
||||||
|
expect(body.events[0].nextOccurrence?.start).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("image optimizer", () => {
|
||||||
|
test("optimizes a CMS-hosted image", async () => {
|
||||||
|
const src = encodeURIComponent(mock("/media/pig.png"));
|
||||||
|
const res = await fetch(app(`/_next/image?url=${src}&w=640&q=75`));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toMatch(/^image\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetch cache and tag revalidation", () => {
|
||||||
|
const path = `/aktuelt/${SLUGS.news}`;
|
||||||
|
|
||||||
|
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)
|
||||||
|
const first = await getPage(path);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.html).toContain("Testartikkel (v1)");
|
||||||
|
|
||||||
|
// the CMS now has v2, but the app must keep serving cached v1
|
||||||
|
await setNewsVersion("v2");
|
||||||
|
const cached = await getPage(path);
|
||||||
|
expect(cached.html).toContain("Testartikkel (v1)");
|
||||||
|
|
||||||
|
// webhook auth: missing/wrong secret is rejected
|
||||||
|
const noSecret = await fetch(app("/api/revalidate"), { method: "POST" });
|
||||||
|
expect(noSecret.status).toBe(401);
|
||||||
|
const wrongSecret = await fetch(app("/api/revalidate"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-revalidate-secret": "wrong" },
|
||||||
|
});
|
||||||
|
expect(wrongSecret.status).toBe(401);
|
||||||
|
|
||||||
|
// correct secret purges the cms cache tag
|
||||||
|
const revalidated = await fetch(app("/api/revalidate"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-revalidate-secret": "smoke-secret" },
|
||||||
|
});
|
||||||
|
expect(revalidated.status).toBe(200);
|
||||||
|
expect(await revalidated.json()).toMatchObject({ revalidated: true });
|
||||||
|
|
||||||
|
// the next render must see v2 (allow a few retries for the refresh)
|
||||||
|
const deadline = Date.now() + 5_000;
|
||||||
|
let html = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
({ html } = await getPage(path));
|
||||||
|
if (html.includes("Testartikkel (v2)")) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(html).toContain("Testartikkel (v2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prerendered pages pick up the change too", async () => {
|
||||||
|
const { html } = await getPage("/");
|
||||||
|
expect(html).toContain("Testartikkel (v2)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("preview", () => {
|
||||||
|
test("draft mode round trip renders the preview page", async () => {
|
||||||
|
const enable = await fetch(
|
||||||
|
app("/api/preview?token=smoke-token&content_type=news.NewsPage"),
|
||||||
|
{ redirect: "manual" }
|
||||||
|
);
|
||||||
|
expect([302, 307]).toContain(enable.status);
|
||||||
|
expect(enable.headers.get("location")).toContain("/preview/render");
|
||||||
|
|
||||||
|
const setCookies = enable.headers.getSetCookie();
|
||||||
|
expect(setCookies.length).toBeGreaterThan(0);
|
||||||
|
const cookieHeader = setCookies
|
||||||
|
.map((c) => c.split(";")[0])
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
const render = await fetch(app("/preview/render"), {
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
});
|
||||||
|
expect(render.status).toBe(200);
|
||||||
|
expect(await render.text()).toContain("Testartikkel");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without the cookies the preview session is expired", async () => {
|
||||||
|
const render = await fetch(app("/preview/render"));
|
||||||
|
expect(render.status).toBe(200);
|
||||||
|
expect(await render.text()).toContain("Preview session expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// Smoke tests: `next build` against a mock CMS, then HTTP assertions against
|
||||||
|
// the standalone server (the artifact the Docker image ships). Run with
|
||||||
|
// `npm run test:smoke`; kept out of `npm test` so unit tests stay fast.
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["tests/smoke/**/*.test.ts"],
|
||||||
|
globalSetup: ["tests/smoke/global-setup.ts"],
|
||||||
|
// the revalidation test mutates shared mock-CMS state
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 30_000,
|
||||||
|
// globalSetup runs a full `next build`
|
||||||
|
hookTimeout: 600_000,
|
||||||
|
env: { TZ: "Europe/Oslo" },
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user