Files

114 lines
3.5 KiB
TypeScript

// 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();
};
}