/** * Integration test for `generate-sections.ts`. * * Drives the script as a child process against a tmp sections-dir fixture, * mirroring the pattern used by migrate-to-cf-observability.test.ts. The * script has no `isMainModule()` guard (unlike generate-schema.ts) — it runs * its filesystem walk and write on import — so it's exercised as a subprocess * rather than imported directly. * * Verifies the operationally important behavior: a co-located test/spec/ * stories/gen file sitting next to a real section must never be walked into * sectionMeta (a production-site incident this generator's `walkDir` reproduced: * `sections.test.ts` became a bogus section in a site's generated output). */ import * as cp from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; const SCRIPT = path.resolve(__dirname, "generate-sections.ts"); function runGenerator( args: string[], opts: { cwd?: string } = {}, ): { stdout: string; stderr: string; code: number } { const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd: opts.cwd }); return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 }; } describe("generate-sections walkDir exclusions", () => { let tmpDir: string; let sectionsDir: string; let outFile: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-")); sectionsDir = path.join(tmpDir, "sections"); outFile = path.join(tmpDir, "out", "sections.gen.ts"); fs.mkdirSync(sectionsDir, { recursive: true }); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("excludes co-located test/spec/stories/gen files from the generated sectionMeta", () => { fs.writeFileSync( path.join(sectionsDir, "Hero.tsx"), `export const eager = true;\nexport default function Hero() { return null; }\n`, ); fs.writeFileSync( path.join(sectionsDir, "Hero.test.tsx"), `export const eager = true;\nexport default function HeroTest() { return null; }\n`, ); fs.writeFileSync( path.join(sectionsDir, "Hero.stories.tsx"), `export const eager = true;\nexport default function HeroStories() { return null; }\n`, ); fs.writeFileSync( path.join(sectionsDir, "sections.gen.ts"), `export const eager = true;\n`, ); const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); expect(generated).toContain("site/sections/Hero.tsx"); expect(generated).not.toContain("Hero.test.tsx"); expect(generated).not.toContain("Hero.stories.tsx"); expect(generated).not.toContain("sections.gen.ts"); }, 30_000); it("extracts `export const deferred = true` into sectionMeta", () => { fs.writeFileSync( path.join(sectionsDir, "HeavyPLP.tsx"), "export const deferred = true;\nexport default function HeavyPLP() { return null; }\n", ); const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); // The per-section deferral flag must survive the regex scan → sectionMeta, // or applySectionConventions can never register it as always-defer. expect(generated).toMatch(/"site\/sections\/HeavyPLP\.tsx":\s*\{[^}]*deferred:\s*true/); expect(generated).toContain("deferred?: boolean;"); }, 30_000); }); describe("generate-sections default output path (.deco/)", () => { let tmpDir: string; let sectionsDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-defaults-")); sectionsDir = path.join(tmpDir, "src", "sections"); fs.mkdirSync(sectionsDir, { recursive: true }); fs.writeFileSync( path.join(sectionsDir, "Hero.tsx"), "export const eager = true;\nexport default function Hero() { return null; }\n", ); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("writes to .deco/sections.gen.ts when no --out-file flag is passed", () => { const { code, stderr } = runGenerator([], { cwd: tmpDir }); expect(code).toBe(0); const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts"); expect(fs.existsSync(newDefault)).toBe(true); expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx"); // No legacy default path is written anymore, so no warning is expected. expect(stderr).not.toContain("Generator default output moved"); }, 30_000); }); describe("generate-sections --registry", () => { let tmpDir: string; let sectionsDir: string; let outFile: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-registry-")); sectionsDir = path.join(tmpDir, "sections"); outFile = path.join(tmpDir, "out", "sections.gen.ts"); fs.mkdirSync(path.join(sectionsDir, "Nested"), { recursive: true }); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); function expectedImportPath(filePath: string): string { let rel = path.relative(path.dirname(outFile), filePath).replace(/\\/g, "/"); if (!rel.startsWith(".")) rel = `./${rel}`; return rel.replace(/\.tsx?$/, ""); } it("emits sectionImports keyed glob-style with relative dynamic imports, built from all scanned section files (not just convention-carrying ones)", () => { const heroPath = path.join(sectionsDir, "Hero.tsx"); const promoPath = path.join(sectionsDir, "Nested", "Promo.tsx"); fs.writeFileSync( heroPath, "export const sync = true\nexport default function Hero() { return null }\n", ); // No convention exports — regression guard: without --registry this file // never makes it into `entries`, so the registry must be built from the // raw `sectionFiles` walk, not from `entries`. fs.writeFileSync( promoPath, "export default function Promo() { return null }\n", ); const { code } = runGenerator([ "--sections-dir", sectionsDir, "--out-file", outFile, "--registry", ]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); expect(generated).toContain("export const sectionImports"); expect(generated).toContain( `"./sections/Hero.tsx": () => import("${expectedImportPath(heroPath)}")`, ); expect(generated).toContain( `"./sections/Nested/Promo.tsx": () => import("${expectedImportPath(promoPath)}")`, ); }, 30_000); it("does not emit sectionImports without the --registry flag", () => { fs.writeFileSync( path.join(sectionsDir, "Hero.tsx"), "export const sync = true\nexport default function Hero() { return null }\n", ); fs.writeFileSync( path.join(sectionsDir, "Nested", "Promo.tsx"), "export default function Promo() { return null }\n", ); const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); expect(generated).not.toContain("sectionImports"); }, 30_000); it("emits a doc comment that does not self-terminate early, and the resulting file is importable (regression: a literal `**/` inside the emitted /** */ comment used to close it prematurely, leaving prose as bare statements and making every --registry output invalid TypeScript)", () => { const heroPath = path.join(sectionsDir, "Hero.tsx"); fs.writeFileSync( heroPath, "export const sync = true\nexport default function Hero() { return null }\n", ); const { code } = runGenerator([ "--sections-dir", sectionsDir, "--out-file", outFile, "--registry", ]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); // Pin the regression directly: the doc comment's body (everything // between the opening `/**` and its own closing `*/`) must contain // exactly one `*/` — the intended closing marker itself, at the very // end. A premature `*/` embedded in the prose (e.g. from a literal // `**/*.tsx` glob pattern) would close the block comment early and // leave the rest of the doc text as bare top-level statements. const docCommentStart = generated.indexOf("/**\n * Lazy section registry"); const exportStart = generated.indexOf("export const sectionImports"); expect(docCommentStart).toBeGreaterThan(-1); expect(exportStart).toBeGreaterThan(docCommentStart); const docComment = generated.slice(docCommentStart + "/**".length, exportStart); const closeMarkerCount = (docComment.match(/\*\//g) ?? []).length; expect(closeMarkerCount).toBe(1); expect(docComment.trimEnd().endsWith("*/")).toBe(true); // Strongest available check: actually import the generated file through // tsx (esbuild) and confirm it parses/executes as valid TS/ESM and // exports `sectionImports`. A premature `*/` would leave trailing prose // as bare top-level statements, which fails to parse. `tsx -e` evaluates // its argument as CommonJS (named exports of a dynamic `import()` come // back CJS-interop-wrapped), so write a real `.mjs` file instead and run // that — mirrors the check used to hand-verify this fix. const checkerFile = path.join(tmpDir, "check-import.mjs"); fs.writeFileSync( checkerFile, [ `import { pathToFileURL } from "node:url";`, `const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`, `if (typeof m.sectionImports !== "object" || m.sectionImports === null) {`, ` throw new Error("sectionImports missing or not an object");`, `}`, ].join("\n"), ); const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" }); expect(importResult.status, importResult.stderr).toBe(0); }, 30_000); it("ends with exactly one trailing newline and no doubled blank line before the registry block (output hygiene)", () => { fs.writeFileSync( path.join(sectionsDir, "Hero.tsx"), "export const sync = true\nexport default function Hero() { return null }\n", ); const { code } = runGenerator([ "--sections-dir", sectionsDir, "--out-file", outFile, "--registry", ]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); expect(generated.endsWith("\n")).toBe(true); expect(generated.endsWith("\n\n")).toBe(false); // No run of 3+ consecutive newlines (i.e. no blank-line pair) anywhere, // in particular right before the registry doc comment. expect(generated).not.toMatch(/\n{3,}/); }, 30_000); }); describe("generate-sections neverDefer convention", () => { // Regression: the scanner recognized `export const neverDefer = true` and // emitted `neverDefer: true` on the section's sectionMeta entry, but the // SectionMetaEntry interface the SAME file declares omitted the field — // so every generated file containing a neverDefer section failed the // site's typecheck (TS2353 excess property), and sites hand-patched the // interface only to have the next regeneration wipe the patch (a production // site's .deco/sections.gen.ts carried exactly that TODO). The emitted interface // must match SectionMetaEntry in @decocms/blocks/cms. let tmpDir: string; let sectionsDir: string; let outFile: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-neverdefer-")); sectionsDir = path.join(tmpDir, "sections"); outFile = path.join(tmpDir, "out", "sections.gen.ts"); fs.mkdirSync(sectionsDir, { recursive: true }); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("declares neverDefer on the emitted SectionMetaEntry interface and the generated file typechecks + imports", () => { // Mirrors a production site's src/sections/Product/SearchResult.tsx. fs.writeFileSync( path.join(sectionsDir, "SearchResult.tsx"), "export const neverDefer = true;\nexport default function SearchResult() { return null; }\n", ); const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); // Entry carries the convention… expect(generated).toMatch(/"site\/sections\/SearchResult\.tsx": \{ neverDefer: true \}/); // …and the interface declares the field (optional boolean, matching // SectionMetaEntry in @decocms/blocks/cms/applySectionConventions.ts). expect(generated).toContain("neverDefer?: boolean;"); // The actual failure mode was a TYPECHECK error (excess property on the // Record literal), which a runtime import // through tsx/esbuild would never catch — so run tsc on the output. const tscResult = cp.spawnSync( "npx", ["tsc", "--noEmit", "--strict", "--skipLibCheck", outFile], { encoding: "utf8" }, ); expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0); // And keep the importability guarantee from the earlier template // regression: the file must load as valid TS/ESM with the expected // exports. const checkerFile = path.join(tmpDir, "check-import.mjs"); fs.writeFileSync( checkerFile, [ `const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`, `if (m.sectionMeta?.["site/sections/SearchResult.tsx"]?.neverDefer !== true) {`, ` throw new Error("neverDefer entry missing from sectionMeta");`, `}`, ].join("\n"), ); const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" }); expect(importResult.status, importResult.stderr).toBe(0); }, 60_000); }); describe("generate-sections renderJson convention", () => { let tmpDir: string; let sectionsDir: string; let outFile: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-renderjson-")); sectionsDir = path.join(tmpDir, "sections"); outFile = path.join(tmpDir, "out", "sections.gen.ts"); fs.mkdirSync(sectionsDir, { recursive: true }); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("emits `renderJson: false` into sectionMeta and a `renderJsons` map for projection fns; the output typechecks + imports", () => { // `= false` opt-out: web-only section dropped from ?renderJson. fs.writeFileSync( path.join(sectionsDir, "Theme.tsx"), "export const renderJson = false;\nexport default function Theme() { return null; }\n", ); // projection function: trims props for the mobile app. fs.writeFileSync( path.join(sectionsDir, "ProductDetails.tsx"), [ "export const renderJson = (props: Record) => {", " const { storeConfig: _s, ...rest } = props;", " return rest;", "};", "export default function ProductDetails() { return null; }", "", ].join("\n"), ); const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); // The `= false` section carries the literal false flag; the fn section // carries hasRenderJson (its actual fn lives in the renderJsons map). expect(generated).toMatch(/"site\/sections\/Theme\.tsx":\s*\{[^}]*renderJson:\s*false/); expect(generated).toMatch(/"site\/sections\/ProductDetails\.tsx":\s*\{[^}]*hasRenderJson:\s*true/); // The emitted interface declares both (excess-property guard, cf. neverDefer). expect(generated).toContain("renderJson?: false;"); expect(generated).toContain("hasRenderJson?: boolean;"); // The fn is imported and wired into renderJsons; the `= false` one is NOT. expect(generated).toContain("import { renderJson as _rj0 }"); expect(generated).toMatch(/export const renderJsons: Record = \{[\s\S]*ProductDetails\.tsx": _rj0/); expect(generated).not.toMatch(/renderJsons: Record = \{[\s\S]*Theme\.tsx/); // Typecheck the output (the real failure mode for a bad emit is TS2353, not // a runtime error) and confirm it imports with a working projection fn. // --jsx: the generated file imports a projection fn from a `.tsx` section, // so tsc needs jsx set (a real site's tsconfig always does; the standalone // invocation must too, or it fails with TS6142). const tscResult = cp.spawnSync( "npx", ["tsc", "--noEmit", "--strict", "--skipLibCheck", "--jsx", "react-jsx", outFile], { encoding: "utf8" }, ); expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0); const checkerFile = path.join(tmpDir, "check-import.mjs"); fs.writeFileSync( checkerFile, [ `const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`, `if (m.sectionMeta?.["site/sections/Theme.tsx"]?.renderJson !== false) {`, ` throw new Error("Theme renderJson:false missing from sectionMeta");`, `}`, `const fn = m.renderJsons?.["site/sections/ProductDetails.tsx"];`, `if (typeof fn !== "function") throw new Error("ProductDetails renderJson fn missing");`, `const out = fn({ storeConfig: 1, page: 2 });`, `if (out.storeConfig !== undefined || out.page !== 2) throw new Error("projection fn wrong");`, ].join("\n"), ); const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" }); expect(importResult.status, importResult.stderr).toBe(0); }, 60_000); }); describe("generate-sections output hygiene (non-registry)", () => { let tmpDir: string; let sectionsDir: string; let outFile: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-hygiene-")); sectionsDir = path.join(tmpDir, "sections"); outFile = path.join(tmpDir, "out", "sections.gen.ts"); fs.mkdirSync(sectionsDir, { recursive: true }); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("ends with exactly one trailing newline without --registry", () => { fs.writeFileSync( path.join(sectionsDir, "Hero.tsx"), "export const eager = true;\nexport default function Hero() { return null; }\n", ); const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); expect(generated.endsWith("\n")).toBe(true); expect(generated.endsWith("\n\n")).toBe(false); expect(generated).not.toMatch(/\n{3,}/); }, 30_000); it("ends with exactly one trailing newline when the sections dir is missing (empty-output early-exit path)", () => { const missingSectionsDir = path.join(tmpDir, "does-not-exist"); const { code } = runGenerator([ "--sections-dir", missingSectionsDir, "--out-file", outFile, ]); expect(code).toBe(0); const generated = fs.readFileSync(outFile, "utf-8"); expect(generated.endsWith("\n")).toBe(true); expect(generated.endsWith("\n\n")).toBe(false); }, 30_000); });