Skip to content

Commit f2ba6b2

Browse files
committed
test(showcase): add no-rebuild env-switch integration test (spike replay)
Replay the Option B runtime-config spike as a vitest integration test that guards the no-rebuild env switching property going forward: - `next build` once with no per-env URL env vars (only a sentinel OPS_BASE_URL so next.config.ts's rewrites() can validate; Next evaluates rewrites at build, not only at start, so a placeholder here is unavoidable — the assertions don't depend on it). - `next start` twice, each on a fresh port and a DIFFERENT POCKETBASE_URL / SHELL_URL / OPS_BASE_URL set. - Fetch `/` on each boot and extract the inlined `window.__SHOWCASE_CONFIG__={...}` JSON from the served HTML. - Assert env-A URLs on the first boot and env-B URLs on the second boot of the SAME built artifact. If anyone re-introduces a build-time URL bake, the second boot's HTML still shows env-A values and this test fails. Test lives at `showcase/shell-dashboard/tests/runtime-env-switch.spike.test.ts` and is picked up via a new vitest include for `tests/**/*.spike.test.ts` (the default include is `src/**/*.test.{ts,tsx}` and the integration- weight spike doesn't fit there). The `.spike.test.ts` suffix keeps the include narrow so the visual snapshot suite under `tests/visual/` stays out. Total wall time locally: ~12s (build ~2s warm with prebuild data generation already run; two boots ~10s combined). Heavy enough to gate behind a `tests:integration` script in CI rather than running on every push.
1 parent cc52235 commit f2ba6b2

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
2+
import { execSync, spawn, type ChildProcess } from "node:child_process";
3+
import { setTimeout as wait } from "node:timers/promises";
4+
5+
// This test boots `next build` ONCE then `next start` TWICE with two
6+
// different POCKETBASE_URL / SHELL_URL / OPS_BASE_URL value sets,
7+
// curls the served HTML on each, and asserts the injected
8+
// __SHOWCASE_CONFIG__ JSON matches the env values for that boot.
9+
//
10+
// If no-rebuild env switching ever regresses (someone re-bakes a URL
11+
// into the bundle), this test fails because the first boot's URL
12+
// values leak into the second boot's HTML.
13+
//
14+
// One nuance vs the original spike: shell-dashboard's next.config.ts
15+
// `rewrites()` reads OPS_BASE_URL and Next.js evaluates rewrites at
16+
// build time (not only at start). So we DO pass an OPS_BASE_URL at
17+
// build — but the value is a sentinel placeholder that no boot uses.
18+
// The properties under test (the inlined __SHOWCASE_CONFIG__ script
19+
// emitted by `src/app/layout.tsx`) are read from process.env at
20+
// request time by `getRuntimeConfig()` and therefore reflect the
21+
// per-boot env values, NOT the build-time placeholder.
22+
23+
const PORT_A = 3801;
24+
const PORT_B = 3802;
25+
26+
// `__dirname` is undefined under ESM (vitest runs ESM); use Node
27+
// 20.11+'s `import.meta.dirname` for the path-to-shell-dashboard
28+
// resolution.
29+
const SHELL_DASHBOARD_DIR = import.meta.dirname + "/..";
30+
31+
// Sentinel used ONLY to satisfy next.config.ts rewrites() during
32+
// `next build`. Per-boot env vars below override this for the
33+
// __SHOWCASE_CONFIG__ injection that the test actually asserts on.
34+
const BUILD_TIME_OPS_PLACEHOLDER = "http://build-placeholder.invalid";
35+
36+
describe("Option B: one artifact, two env values, no rebuild", () => {
37+
beforeAll(() => {
38+
// Build ONCE. Pass a placeholder OPS_BASE_URL only so
39+
// next.config.ts:rewrites() can construct its proxy entry.
40+
// No POCKETBASE_URL / SHELL_URL — those are read at request
41+
// time by getRuntimeConfig() and must not be required at build.
42+
//
43+
// Use `npm run build` (not `npx next build` directly) so the
44+
// `prebuild` script runs and the generated data fixtures
45+
// (`src/data/registry.json`, `catalog.json`, `docs-status.json`)
46+
// exist before webpack walks the import tree.
47+
execSync("npm run build", {
48+
cwd: SHELL_DASHBOARD_DIR,
49+
stdio: "inherit",
50+
env: {
51+
...process.env,
52+
NODE_ENV: "production",
53+
OPS_BASE_URL: BUILD_TIME_OPS_PLACEHOLDER,
54+
},
55+
});
56+
}, 180_000);
57+
58+
async function bootAndProbe(
59+
port: number,
60+
env: Record<string, string>,
61+
): Promise<{ pocketbaseUrl: string; shellUrl: string; opsBaseUrl: string }> {
62+
const proc: ChildProcess = spawn(
63+
"npx",
64+
["next", "start", "-p", String(port)],
65+
{
66+
cwd: SHELL_DASHBOARD_DIR,
67+
env: { ...process.env, NODE_ENV: "production", ...env },
68+
stdio: "pipe",
69+
detached: false,
70+
},
71+
);
72+
try {
73+
// Wait for the server to be ready by polling /.
74+
const deadline = Date.now() + 30_000;
75+
while (Date.now() < deadline) {
76+
try {
77+
const res = await fetch(`http://localhost:${port}/`);
78+
if (res.ok) break;
79+
} catch {
80+
// not up yet
81+
}
82+
await wait(500);
83+
}
84+
const html = await (await fetch(`http://localhost:${port}/`)).text();
85+
// Extract the inlined runtime config from the injected
86+
// <script id="__showcase_config__">. The injection pattern
87+
// is `window.__SHOWCASE_CONFIG__={"...":"..."};`.
88+
const match = html.match(
89+
/window\.__SHOWCASE_CONFIG__=(\{[^<]*?\});/,
90+
);
91+
if (!match) throw new Error("no __SHOWCASE_CONFIG__ in HTML");
92+
// The serialized payload has < escaped as < — JSON.parse
93+
// accepts it as-is for our keys (URLs don't contain <).
94+
const parsed = JSON.parse(match[1]);
95+
return parsed;
96+
} finally {
97+
proc.kill("SIGTERM");
98+
// Drain — give the OS a beat to release the port before the
99+
// next iteration binds it.
100+
await wait(500);
101+
}
102+
}
103+
104+
it("serves env-A URLs on the first boot", async () => {
105+
const cfg = await bootAndProbe(PORT_A, {
106+
POCKETBASE_URL: "https://pb-env-a.example.com",
107+
SHELL_URL: "https://shell-env-a.example.com",
108+
OPS_BASE_URL: "https://ops-env-a.example.com",
109+
});
110+
expect(cfg.pocketbaseUrl).toBe("https://pb-env-a.example.com");
111+
expect(cfg.shellUrl).toBe("https://shell-env-a.example.com");
112+
expect(cfg.opsBaseUrl).toBe("https://ops-env-a.example.com");
113+
}, 60_000);
114+
115+
it("serves env-B URLs on the second boot of THE SAME ARTIFACT", async () => {
116+
const cfg = await bootAndProbe(PORT_B, {
117+
POCKETBASE_URL: "https://pb-env-b.example.com",
118+
SHELL_URL: "https://shell-env-b.example.com",
119+
OPS_BASE_URL: "https://ops-env-b.example.com",
120+
});
121+
expect(cfg.pocketbaseUrl).toBe("https://pb-env-b.example.com");
122+
expect(cfg.shellUrl).toBe("https://shell-env-b.example.com");
123+
expect(cfg.opsBaseUrl).toBe("https://ops-env-b.example.com");
124+
}, 60_000);
125+
126+
afterAll(() => {
127+
// Nothing to clean — the .next/ artifact is intentionally
128+
// left in place for inspection; the test doesn't touch it
129+
// between runs.
130+
});
131+
});

showcase/shell-dashboard/vitest.config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ export default defineConfig({
88
environment: "jsdom",
99
globals: true,
1010
setupFiles: ["./src/test/setup.ts"],
11-
include: ["src/**/*.test.{ts,tsx}"],
11+
// Default unit tests live under src/; the spike-replay integration
12+
// test for runtime env switching (B13) lives under tests/ because it
13+
// spawns `next build` + two `next start` invocations and is too heavy
14+
// for the per-file unit suite. The `.spike.test.ts` suffix scopes the
15+
// tests/-rooted include narrowly so visual snapshots stay out.
16+
include: [
17+
"src/**/*.test.{ts,tsx}",
18+
"tests/**/*.spike.test.ts",
19+
],
1220
exclude: ["tests/visual/**", "node_modules/**"],
1321
},
1422
resolve: {

0 commit comments

Comments
 (0)