/** * Phase 18.τ — plugin lifecycle integration tests. * * Pins the narrow wirings the τ agent owns: * - `generateManifest()` fires `onRouteRegistered` + `onManifestBuilt` * - `prerenderRoutes()` fires `definePrerenderHook` * - `definePlugin()` validates + passes through * * Real filesystem fixtures, `bun:test` harness. */ import { describe, it, expect, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync } from "fs"; import { tmpdir } from "os"; import path from "path"; import { generateManifest } from "../../router/fs-routes"; import { prerenderRoutes, type PrerenderOptions, } from "../../bundler/prerender"; import type { RouteSpec, RoutesManifest } from "../../spec/schema"; import type { ManduPlugin } from "../hooks"; import { definePlugin, isManduPlugin } from "../define"; // ═══════════════════════════════════════════════════════════════════════ // Fixture helpers // ═══════════════════════════════════════════════════════════════════════ function makeProject(): string { const dir = mkdtempSync(path.join(tmpdir(), "mandu-plugin-")); mkdirSync(path.join(dir, "app", "about"), { recursive: true }); writeFileSync( path.join(dir, "app", "page.tsx"), "export default function Home() { return
Home
; }\n", ); writeFileSync( path.join(dir, "app", "about", "page.tsx"), "export default function About() { return
About
; }\n", ); return dir; } function cleanup(dir: string): void { try { rmSync(dir, { recursive: true, force: true }); } catch {} } // ═══════════════════════════════════════════════════════════════════════ describe("generateManifest — onRouteRegistered", () => { let project: string; beforeAll(() => { project = makeProject(); }); afterAll(() => cleanup(project)); it("fires onRouteRegistered for each discovered route", async () => { const seen: string[] = []; const plugins: ManduPlugin[] = [ { name: "observer", hooks: { onRouteRegistered: (r) => { seen.push(r.id); } }, }, ]; await generateManifest(project, { outputPath: ".mandu/routes.manifest.json", plugins, }); expect(seen.length).toBeGreaterThanOrEqual(2); // home + about }); it("surfaces plugin errors as warnings, doesn't abort scan", async () => { const plugins: ManduPlugin[] = [ { name: "bad", hooks: { onRouteRegistered: () => { throw new Error("route boom"); }, }, }, ]; const result = await generateManifest(project, { outputPath: ".mandu/routes.manifest.json", plugins, }); expect(result.manifest.routes.length).toBeGreaterThan(0); // At least one warning mentions the failing plugin const badWarnings = result.warnings.filter((w) => w.includes("bad")); expect(badWarnings.length).toBeGreaterThan(0); }); }); // ═══════════════════════════════════════════════════════════════════════ describe("generateManifest — onManifestBuilt", () => { let project: string; beforeAll(() => { project = makeProject(); }); afterAll(() => cleanup(project)); it("allows a plugin to mutate the manifest before write", async () => { const plugins: ManduPlugin[] = [ { name: "mutator", hooks: { onManifestBuilt: (manifest) => ({ ...manifest, version: 99 as 1, // Tag every route with a marker via a side-channel field. routes: manifest.routes.map((r) => ({ ...r, module: r.module, // unchanged but exercises the clone })), }), }, }, ]; const result = await generateManifest(project, { outputPath: ".mandu/routes.manifest.json", plugins, }); expect(result.manifest.version).toBe(99); // Verify on-disk file also reflects mutation. const onDisk = JSON.parse( readFileSync( path.join(project, ".mandu", "routes.manifest.json"), "utf-8", ), ) as RoutesManifest; expect(onDisk.version).toBe(99); }); it("pipes manifest through multiple plugins in order", async () => { const plugins: ManduPlugin[] = [ { name: "inc-a", hooks: { onManifestBuilt: (m) => ({ ...m, version: (m.version + 10) as 1 }), }, }, { name: "inc-b", hooks: { onManifestBuilt: (m) => ({ ...m, version: (m.version + 5) as 1 }), }, }, ]; const result = await generateManifest(project, { outputPath: ".mandu/routes.manifest.json", plugins, }); // 1 -> 11 -> 16 expect(result.manifest.version).toBe(16); }); }); // ═══════════════════════════════════════════════════════════════════════ describe("prerenderRoutes — definePrerenderHook", () => { let project: string; beforeAll(() => { project = makeProject(); }); afterAll(() => cleanup(project)); const staticManifest: RoutesManifest = { version: 1, routes: [ { id: "home", kind: "page", pattern: "/", module: "app/page.tsx", componentModule: "app/page.tsx", } as RouteSpec, ], }; const fakeFetchHandler = (body: string) => async (_req: Request) => new Response(body, { status: 200, headers: { "content-type": "text/html" }, }); it("plugin can rewrite prerendered HTML", async () => { const outDir = path.join(project, ".mandu", "prerendered-override"); const plugins: ManduPlugin[] = [ { name: "rewriter", hooks: { definePrerenderHook: (ctx) => ({ html: `\n${ctx.html}`, }), }, }, ]; const opts: PrerenderOptions = { rootDir: project, outDir, plugins, }; await prerenderRoutes(staticManifest, fakeFetchHandler("

Hi

"), opts); const written = readFileSync(path.join(outDir, "index.html"), "utf-8"); expect(written).toContain("rewritten for /"); expect(written).toContain("

Hi

"); }); it("plugin can skip a page entirely", async () => { const outDir = path.join(project, ".mandu", "prerendered-skip"); const plugins: ManduPlugin[] = [ { name: "skipper", hooks: { definePrerenderHook: (ctx) => ctx.pathname === "/" ? { skip: true } : undefined, }, }, ]; const result = await prerenderRoutes( staticManifest, fakeFetchHandler("

Hi

"), { rootDir: project, outDir, plugins }, ); expect(result.pages.find((p) => p.path === "/")).toBeUndefined(); }); it("no plugins → baseline behaviour unchanged", async () => { const outDir = path.join(project, ".mandu", "prerendered-noop"); const result = await prerenderRoutes( staticManifest, fakeFetchHandler("

Hi

"), { rootDir: project, outDir }, ); expect(result.pages.map((p) => p.path)).toContain("/"); }); }); // ═══════════════════════════════════════════════════════════════════════ describe("definePlugin helper", () => { it("passes through a valid plugin", () => { const p = definePlugin({ name: "ok", hooks: { onRouteRegistered: () => {} }, }); expect(p.name).toBe("ok"); expect(isManduPlugin(p)).toBe(true); }); it("rejects missing name", () => { expect(() => definePlugin({} as ManduPlugin)).toThrow(); expect(() => definePlugin({ name: "" } as ManduPlugin)).toThrow(); }); it("rejects unknown hook names", () => { expect(() => definePlugin({ name: "typo", hooks: { onRouteRegitered: () => {} } as never, }), ).toThrow(/unknown hook/); }); it("rejects non-function hook values", () => { expect(() => definePlugin({ name: "wrong-shape", hooks: { onRouteRegistered: "not a function" as never }, }), ).toThrow(/must be a function/); }); it("rejects non-function setup", () => { expect(() => definePlugin({ name: "bad-setup", setup: "nope" as never, }), ).toThrow(/setup must be a function/); }); });