@mandujs/core 0.32.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/bundler/build.ts +29 -1
- package/src/bundler/generate-static-params.ts +302 -290
- package/src/bundler/prerender.ts +858 -368
- package/src/bundler/types.ts +20 -0
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +54 -0
- package/src/config/validate.ts +55 -0
- package/src/diagnose/__tests__/checks.test.ts +378 -0
- package/src/diagnose/checks.ts +599 -0
- package/src/diagnose/index.ts +15 -0
- package/src/diagnose/run.ts +87 -0
- package/src/diagnose/types.ts +53 -0
- package/src/guard/graph.ts +898 -0
- package/src/guard/index.ts +14 -0
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
- package/src/plugins/__tests__/runner.test.ts +409 -0
- package/src/plugins/define.ts +124 -0
- package/src/plugins/examples/dep-check-plugin.ts +80 -0
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
- package/src/plugins/examples/sitemap-plugin.ts +65 -0
- package/src/plugins/hooks.ts +297 -64
- package/src/plugins/index.ts +80 -41
- package/src/plugins/runner.ts +361 -0
- package/src/router/fs-routes.ts +64 -1
- package/src/runtime/server.ts +365 -36
- package/src/spec/schema.ts +25 -0
- package/src/testing/__tests__/reporter.test.ts +454 -0
- package/src/testing/index.ts +29 -0
- package/src/testing/reporter.ts +676 -0
package/src/guard/index.ts
CHANGED
|
@@ -301,6 +301,20 @@ export {
|
|
|
301
301
|
type RequirePrefixForExportsOptions,
|
|
302
302
|
} from "./rule-presets";
|
|
303
303
|
|
|
304
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
305
|
+
// Guard Graph - Phase 18.π dependency graph visualization
|
|
306
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
307
|
+
|
|
308
|
+
export {
|
|
309
|
+
analyzeDependencyGraph,
|
|
310
|
+
renderGraphHtml,
|
|
311
|
+
type ModuleNode,
|
|
312
|
+
type ImportEdge,
|
|
313
|
+
type Layer as GraphLayer,
|
|
314
|
+
type GraphSummary,
|
|
315
|
+
type DependencyGraph,
|
|
316
|
+
} from "./graph";
|
|
317
|
+
|
|
304
318
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
305
319
|
// Architecture Negotiation - AI-Framework 협상
|
|
306
320
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.τ — plugin lifecycle integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Pins the narrow wirings the τ agent owns:
|
|
5
|
+
* - `generateManifest()` fires `onRouteRegistered` + `onManifestBuilt`
|
|
6
|
+
* - `prerenderRoutes()` fires `definePrerenderHook`
|
|
7
|
+
* - `definePlugin()` validates + passes through
|
|
8
|
+
*
|
|
9
|
+
* Real filesystem fixtures, `bun:test` harness.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
13
|
+
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { generateManifest } from "../../router/fs-routes";
|
|
17
|
+
import {
|
|
18
|
+
prerenderRoutes,
|
|
19
|
+
type PrerenderOptions,
|
|
20
|
+
} from "../../bundler/prerender";
|
|
21
|
+
import type { RouteSpec, RoutesManifest } from "../../spec/schema";
|
|
22
|
+
import type { ManduPlugin } from "../hooks";
|
|
23
|
+
import { definePlugin, isManduPlugin } from "../define";
|
|
24
|
+
|
|
25
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
26
|
+
// Fixture helpers
|
|
27
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
function makeProject(): string {
|
|
30
|
+
const dir = mkdtempSync(path.join(tmpdir(), "mandu-plugin-"));
|
|
31
|
+
mkdirSync(path.join(dir, "app", "about"), { recursive: true });
|
|
32
|
+
writeFileSync(
|
|
33
|
+
path.join(dir, "app", "page.tsx"),
|
|
34
|
+
"export default function Home() { return <div>Home</div>; }\n",
|
|
35
|
+
);
|
|
36
|
+
writeFileSync(
|
|
37
|
+
path.join(dir, "app", "about", "page.tsx"),
|
|
38
|
+
"export default function About() { return <div>About</div>; }\n",
|
|
39
|
+
);
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cleanup(dir: string): void {
|
|
44
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
48
|
+
describe("generateManifest — onRouteRegistered", () => {
|
|
49
|
+
let project: string;
|
|
50
|
+
beforeAll(() => { project = makeProject(); });
|
|
51
|
+
afterAll(() => cleanup(project));
|
|
52
|
+
|
|
53
|
+
it("fires onRouteRegistered for each discovered route", async () => {
|
|
54
|
+
const seen: string[] = [];
|
|
55
|
+
const plugins: ManduPlugin[] = [
|
|
56
|
+
{
|
|
57
|
+
name: "observer",
|
|
58
|
+
hooks: { onRouteRegistered: (r) => { seen.push(r.id); } },
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
await generateManifest(project, {
|
|
62
|
+
outputPath: ".mandu/routes.manifest.json",
|
|
63
|
+
plugins,
|
|
64
|
+
});
|
|
65
|
+
expect(seen.length).toBeGreaterThanOrEqual(2); // home + about
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("surfaces plugin errors as warnings, doesn't abort scan", async () => {
|
|
69
|
+
const plugins: ManduPlugin[] = [
|
|
70
|
+
{
|
|
71
|
+
name: "bad",
|
|
72
|
+
hooks: {
|
|
73
|
+
onRouteRegistered: () => { throw new Error("route boom"); },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
const result = await generateManifest(project, {
|
|
78
|
+
outputPath: ".mandu/routes.manifest.json",
|
|
79
|
+
plugins,
|
|
80
|
+
});
|
|
81
|
+
expect(result.manifest.routes.length).toBeGreaterThan(0);
|
|
82
|
+
// At least one warning mentions the failing plugin
|
|
83
|
+
const badWarnings = result.warnings.filter((w) => w.includes("bad"));
|
|
84
|
+
expect(badWarnings.length).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
89
|
+
describe("generateManifest — onManifestBuilt", () => {
|
|
90
|
+
let project: string;
|
|
91
|
+
beforeAll(() => { project = makeProject(); });
|
|
92
|
+
afterAll(() => cleanup(project));
|
|
93
|
+
|
|
94
|
+
it("allows a plugin to mutate the manifest before write", async () => {
|
|
95
|
+
const plugins: ManduPlugin[] = [
|
|
96
|
+
{
|
|
97
|
+
name: "mutator",
|
|
98
|
+
hooks: {
|
|
99
|
+
onManifestBuilt: (manifest) => ({
|
|
100
|
+
...manifest,
|
|
101
|
+
version: 99 as 1,
|
|
102
|
+
// Tag every route with a marker via a side-channel field.
|
|
103
|
+
routes: manifest.routes.map((r) => ({
|
|
104
|
+
...r,
|
|
105
|
+
module: r.module, // unchanged but exercises the clone
|
|
106
|
+
})),
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
const result = await generateManifest(project, {
|
|
112
|
+
outputPath: ".mandu/routes.manifest.json",
|
|
113
|
+
plugins,
|
|
114
|
+
});
|
|
115
|
+
expect(result.manifest.version).toBe(99);
|
|
116
|
+
|
|
117
|
+
// Verify on-disk file also reflects mutation.
|
|
118
|
+
const onDisk = JSON.parse(
|
|
119
|
+
readFileSync(
|
|
120
|
+
path.join(project, ".mandu", "routes.manifest.json"),
|
|
121
|
+
"utf-8",
|
|
122
|
+
),
|
|
123
|
+
) as RoutesManifest;
|
|
124
|
+
expect(onDisk.version).toBe(99);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("pipes manifest through multiple plugins in order", async () => {
|
|
128
|
+
const plugins: ManduPlugin[] = [
|
|
129
|
+
{
|
|
130
|
+
name: "inc-a",
|
|
131
|
+
hooks: {
|
|
132
|
+
onManifestBuilt: (m) => ({ ...m, version: (m.version + 10) as 1 }),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "inc-b",
|
|
137
|
+
hooks: {
|
|
138
|
+
onManifestBuilt: (m) => ({ ...m, version: (m.version + 5) as 1 }),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
const result = await generateManifest(project, {
|
|
143
|
+
outputPath: ".mandu/routes.manifest.json",
|
|
144
|
+
plugins,
|
|
145
|
+
});
|
|
146
|
+
// 1 -> 11 -> 16
|
|
147
|
+
expect(result.manifest.version).toBe(16);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
152
|
+
describe("prerenderRoutes — definePrerenderHook", () => {
|
|
153
|
+
let project: string;
|
|
154
|
+
beforeAll(() => { project = makeProject(); });
|
|
155
|
+
afterAll(() => cleanup(project));
|
|
156
|
+
|
|
157
|
+
const staticManifest: RoutesManifest = {
|
|
158
|
+
version: 1,
|
|
159
|
+
routes: [
|
|
160
|
+
{
|
|
161
|
+
id: "home",
|
|
162
|
+
kind: "page",
|
|
163
|
+
pattern: "/",
|
|
164
|
+
module: "app/page.tsx",
|
|
165
|
+
componentModule: "app/page.tsx",
|
|
166
|
+
} as RouteSpec,
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const fakeFetchHandler = (body: string) =>
|
|
171
|
+
async (_req: Request) =>
|
|
172
|
+
new Response(body, {
|
|
173
|
+
status: 200,
|
|
174
|
+
headers: { "content-type": "text/html" },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("plugin can rewrite prerendered HTML", async () => {
|
|
178
|
+
const outDir = path.join(project, ".mandu", "prerendered-override");
|
|
179
|
+
const plugins: ManduPlugin[] = [
|
|
180
|
+
{
|
|
181
|
+
name: "rewriter",
|
|
182
|
+
hooks: {
|
|
183
|
+
definePrerenderHook: (ctx) => ({
|
|
184
|
+
html: `<!-- rewritten for ${ctx.pathname} -->\n${ctx.html}`,
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
const opts: PrerenderOptions = {
|
|
190
|
+
rootDir: project,
|
|
191
|
+
outDir,
|
|
192
|
+
plugins,
|
|
193
|
+
};
|
|
194
|
+
await prerenderRoutes(staticManifest, fakeFetchHandler("<p>Hi</p>"), opts);
|
|
195
|
+
const written = readFileSync(path.join(outDir, "index.html"), "utf-8");
|
|
196
|
+
expect(written).toContain("rewritten for /");
|
|
197
|
+
expect(written).toContain("<p>Hi</p>");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("plugin can skip a page entirely", async () => {
|
|
201
|
+
const outDir = path.join(project, ".mandu", "prerendered-skip");
|
|
202
|
+
const plugins: ManduPlugin[] = [
|
|
203
|
+
{
|
|
204
|
+
name: "skipper",
|
|
205
|
+
hooks: {
|
|
206
|
+
definePrerenderHook: (ctx) =>
|
|
207
|
+
ctx.pathname === "/" ? { skip: true } : undefined,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
const result = await prerenderRoutes(
|
|
212
|
+
staticManifest,
|
|
213
|
+
fakeFetchHandler("<p>Hi</p>"),
|
|
214
|
+
{ rootDir: project, outDir, plugins },
|
|
215
|
+
);
|
|
216
|
+
expect(result.pages.find((p) => p.path === "/")).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("no plugins → baseline behaviour unchanged", async () => {
|
|
220
|
+
const outDir = path.join(project, ".mandu", "prerendered-noop");
|
|
221
|
+
const result = await prerenderRoutes(
|
|
222
|
+
staticManifest,
|
|
223
|
+
fakeFetchHandler("<p>Hi</p>"),
|
|
224
|
+
{ rootDir: project, outDir },
|
|
225
|
+
);
|
|
226
|
+
expect(result.pages.map((p) => p.path)).toContain("/");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
231
|
+
describe("definePlugin helper", () => {
|
|
232
|
+
it("passes through a valid plugin", () => {
|
|
233
|
+
const p = definePlugin({
|
|
234
|
+
name: "ok",
|
|
235
|
+
hooks: { onRouteRegistered: () => {} },
|
|
236
|
+
});
|
|
237
|
+
expect(p.name).toBe("ok");
|
|
238
|
+
expect(isManduPlugin(p)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("rejects missing name", () => {
|
|
242
|
+
expect(() => definePlugin({} as ManduPlugin)).toThrow();
|
|
243
|
+
expect(() => definePlugin({ name: "" } as ManduPlugin)).toThrow();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("rejects unknown hook names", () => {
|
|
247
|
+
expect(() =>
|
|
248
|
+
definePlugin({
|
|
249
|
+
name: "typo",
|
|
250
|
+
hooks: { onRouteRegitered: () => {} } as never,
|
|
251
|
+
}),
|
|
252
|
+
).toThrow(/unknown hook/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects non-function hook values", () => {
|
|
256
|
+
expect(() =>
|
|
257
|
+
definePlugin({
|
|
258
|
+
name: "wrong-shape",
|
|
259
|
+
hooks: { onRouteRegistered: "not a function" as never },
|
|
260
|
+
}),
|
|
261
|
+
).toThrow(/must be a function/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("rejects non-function setup", () => {
|
|
265
|
+
expect(() =>
|
|
266
|
+
definePlugin({
|
|
267
|
+
name: "bad-setup",
|
|
268
|
+
setup: "nope" as never,
|
|
269
|
+
}),
|
|
270
|
+
).toThrow(/setup must be a function/);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.τ — canonical plugin runner tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers every hook type and merge-semantics edge case the runner owns.
|
|
5
|
+
* Real `ManduPlugin` objects, no mocks; the runner is pure so we can
|
|
6
|
+
* assert outputs directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from "bun:test";
|
|
10
|
+
import type { BunPlugin } from "bun";
|
|
11
|
+
import type { Middleware } from "../../middleware/define";
|
|
12
|
+
import type { RouteSpec, RoutesManifest } from "../../spec/schema";
|
|
13
|
+
import type { BundleStats } from "../../bundler/types";
|
|
14
|
+
import type { ManduPlugin } from "../hooks";
|
|
15
|
+
import {
|
|
16
|
+
runOnRouteRegistered,
|
|
17
|
+
runOnBundleComplete,
|
|
18
|
+
runDefinePrerenderHook,
|
|
19
|
+
runOnManifestBuilt,
|
|
20
|
+
runDefineBundlerPlugin,
|
|
21
|
+
runDefineMiddlewareChain,
|
|
22
|
+
runDefineTestTransform,
|
|
23
|
+
resolvePluginMiddleware,
|
|
24
|
+
formatHookErrors,
|
|
25
|
+
} from "../runner";
|
|
26
|
+
|
|
27
|
+
// ───── Fixtures ─────
|
|
28
|
+
|
|
29
|
+
const sampleRoute: RouteSpec = {
|
|
30
|
+
id: "sample",
|
|
31
|
+
kind: "page",
|
|
32
|
+
pattern: "/",
|
|
33
|
+
module: "app/page.tsx",
|
|
34
|
+
componentModule: "app/page.tsx",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const sampleManifest: RoutesManifest = {
|
|
38
|
+
version: 1,
|
|
39
|
+
routes: [sampleRoute],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const sampleStats: BundleStats = {
|
|
43
|
+
totalSize: 1024,
|
|
44
|
+
totalGzipSize: 512,
|
|
45
|
+
largestBundle: { routeId: "sample", size: 1024 },
|
|
46
|
+
buildTime: 50,
|
|
47
|
+
bundleCount: 1,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const mkPluginContext = () => ({
|
|
51
|
+
rootDir: "/tmp/project",
|
|
52
|
+
mode: "production" as const,
|
|
53
|
+
logger: {
|
|
54
|
+
debug: () => {},
|
|
55
|
+
info: () => {},
|
|
56
|
+
warn: () => {},
|
|
57
|
+
error: () => {},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
62
|
+
describe("runner — dispatch order", () => {
|
|
63
|
+
it("runs config hook BEFORE plugin hooks", async () => {
|
|
64
|
+
const log: string[] = [];
|
|
65
|
+
const plugins: ManduPlugin[] = [
|
|
66
|
+
{
|
|
67
|
+
name: "p1",
|
|
68
|
+
hooks: {
|
|
69
|
+
onRouteRegistered: () => {
|
|
70
|
+
log.push("p1");
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
const configHooks = {
|
|
76
|
+
onRouteRegistered: () => {
|
|
77
|
+
log.push("config");
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
await runOnRouteRegistered(sampleRoute, { plugins, configHooks });
|
|
81
|
+
expect(log).toEqual(["config", "p1"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("runs plugin hooks in declaration order", async () => {
|
|
85
|
+
const log: string[] = [];
|
|
86
|
+
const plugins: ManduPlugin[] = [
|
|
87
|
+
{ name: "a", hooks: { onRouteRegistered: () => { log.push("a"); } } },
|
|
88
|
+
{ name: "b", hooks: { onRouteRegistered: () => { log.push("b"); } } },
|
|
89
|
+
{ name: "c", hooks: { onRouteRegistered: () => { log.push("c"); } } },
|
|
90
|
+
];
|
|
91
|
+
await runOnRouteRegistered(sampleRoute, { plugins });
|
|
92
|
+
expect(log).toEqual(["a", "b", "c"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("skips undefined hooks silently", async () => {
|
|
96
|
+
const plugins: ManduPlugin[] = [
|
|
97
|
+
{ name: "empty" },
|
|
98
|
+
{ name: "also-empty", hooks: {} },
|
|
99
|
+
];
|
|
100
|
+
const report = await runOnRouteRegistered(sampleRoute, { plugins });
|
|
101
|
+
expect(report.errors).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles empty plugin array gracefully", async () => {
|
|
105
|
+
const report = await runOnBundleComplete(sampleStats, { plugins: [] });
|
|
106
|
+
expect(report.errors).toEqual([]);
|
|
107
|
+
expect(report.result).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
112
|
+
describe("runner — error isolation", () => {
|
|
113
|
+
it("captures errors without stopping sibling plugins", async () => {
|
|
114
|
+
const log: string[] = [];
|
|
115
|
+
const plugins: ManduPlugin[] = [
|
|
116
|
+
{
|
|
117
|
+
name: "ok1",
|
|
118
|
+
hooks: { onRouteRegistered: () => { log.push("ok1"); } },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "fails",
|
|
122
|
+
hooks: {
|
|
123
|
+
onRouteRegistered: () => {
|
|
124
|
+
throw new Error("boom");
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "ok2",
|
|
130
|
+
hooks: { onRouteRegistered: () => { log.push("ok2"); } },
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
const report = await runOnRouteRegistered(sampleRoute, { plugins });
|
|
134
|
+
expect(log).toEqual(["ok1", "ok2"]);
|
|
135
|
+
expect(report.errors).toHaveLength(1);
|
|
136
|
+
expect(report.errors[0].source).toBe("fails");
|
|
137
|
+
expect(report.errors[0].hook).toBe("onRouteRegistered");
|
|
138
|
+
expect(report.errors[0].error.message).toBe("boom");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("handles async errors", async () => {
|
|
142
|
+
const plugins: ManduPlugin[] = [
|
|
143
|
+
{
|
|
144
|
+
name: "async-fails",
|
|
145
|
+
hooks: {
|
|
146
|
+
async onRouteRegistered() {
|
|
147
|
+
await Promise.resolve();
|
|
148
|
+
throw new Error("async boom");
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
const report = await runOnRouteRegistered(sampleRoute, { plugins });
|
|
154
|
+
expect(report.errors[0].error.message).toBe("async boom");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("stringifies non-Error throws", async () => {
|
|
158
|
+
const plugins: ManduPlugin[] = [
|
|
159
|
+
{
|
|
160
|
+
name: "weird",
|
|
161
|
+
hooks: {
|
|
162
|
+
onRouteRegistered: () => {
|
|
163
|
+
throw "just a string";
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
const report = await runOnRouteRegistered(sampleRoute, { plugins });
|
|
169
|
+
expect(report.errors[0].error.message).toBe("just a string");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
174
|
+
describe("runner — definePrerenderHook merge", () => {
|
|
175
|
+
it("spreads returns across plugins (last write wins)", async () => {
|
|
176
|
+
const plugins: ManduPlugin[] = [
|
|
177
|
+
{
|
|
178
|
+
name: "first",
|
|
179
|
+
hooks: {
|
|
180
|
+
definePrerenderHook: () => ({ html: "<p>first</p>", skip: false }),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "second",
|
|
185
|
+
hooks: {
|
|
186
|
+
definePrerenderHook: () => ({ html: "<p>second</p>" }),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
const report = await runDefinePrerenderHook(
|
|
191
|
+
{
|
|
192
|
+
...mkPluginContext(),
|
|
193
|
+
pathname: "/",
|
|
194
|
+
html: "<p>original</p>",
|
|
195
|
+
},
|
|
196
|
+
{ plugins },
|
|
197
|
+
);
|
|
198
|
+
expect(report.result.html).toBe("<p>second</p>");
|
|
199
|
+
expect(report.result.skip).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("treats void returns as no-change", async () => {
|
|
203
|
+
const plugins: ManduPlugin[] = [
|
|
204
|
+
{ name: "noop", hooks: { definePrerenderHook: () => {} } },
|
|
205
|
+
{
|
|
206
|
+
name: "sets",
|
|
207
|
+
hooks: { definePrerenderHook: () => ({ skip: true }) },
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
const report = await runDefinePrerenderHook(
|
|
211
|
+
{ ...mkPluginContext(), pathname: "/", html: "x" },
|
|
212
|
+
{ plugins },
|
|
213
|
+
);
|
|
214
|
+
expect(report.result.skip).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
219
|
+
describe("runner — onManifestBuilt pipe", () => {
|
|
220
|
+
it("pipes manifest across plugins (each sees prev output)", async () => {
|
|
221
|
+
const plugins: ManduPlugin[] = [
|
|
222
|
+
{
|
|
223
|
+
name: "add-version",
|
|
224
|
+
hooks: {
|
|
225
|
+
onManifestBuilt: (m) => ({ ...m, version: 2 }) as RoutesManifest,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "inspect",
|
|
230
|
+
hooks: {
|
|
231
|
+
onManifestBuilt: (m) => {
|
|
232
|
+
expect(m.version).toBe(2);
|
|
233
|
+
return m;
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
const report = await runOnManifestBuilt(sampleManifest, { plugins });
|
|
239
|
+
expect(report.result.version).toBe(2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("void return passes through unchanged", async () => {
|
|
243
|
+
const plugins: ManduPlugin[] = [
|
|
244
|
+
{ name: "noop", hooks: { onManifestBuilt: () => {} } },
|
|
245
|
+
];
|
|
246
|
+
const report = await runOnManifestBuilt(sampleManifest, { plugins });
|
|
247
|
+
expect(report.result).toBe(sampleManifest);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
252
|
+
describe("runner — defineBundlerPlugin concat", () => {
|
|
253
|
+
const mkPlugin = (name: string): BunPlugin => ({
|
|
254
|
+
name,
|
|
255
|
+
setup() {},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("concatenates scalar + array returns", async () => {
|
|
259
|
+
const plugins: ManduPlugin[] = [
|
|
260
|
+
{
|
|
261
|
+
name: "single",
|
|
262
|
+
hooks: { defineBundlerPlugin: () => mkPlugin("one") },
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "multi",
|
|
266
|
+
hooks: {
|
|
267
|
+
defineBundlerPlugin: () => [mkPlugin("two"), mkPlugin("three")],
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
const report = await runDefineBundlerPlugin({ plugins });
|
|
272
|
+
expect(report.result.map((p) => p.name)).toEqual(["one", "two", "three"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("skips undefined returns", async () => {
|
|
276
|
+
const plugins: ManduPlugin[] = [
|
|
277
|
+
{ name: "nil", hooks: { defineBundlerPlugin: () => undefined } as any },
|
|
278
|
+
{ name: "one", hooks: { defineBundlerPlugin: () => mkPlugin("x") } },
|
|
279
|
+
];
|
|
280
|
+
const report = await runDefineBundlerPlugin({ plugins });
|
|
281
|
+
expect(report.result.map((p) => p.name)).toEqual(["x"]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
286
|
+
describe("runner — defineMiddlewareChain concat", () => {
|
|
287
|
+
const mkMw = (name: string): Middleware => ({
|
|
288
|
+
name,
|
|
289
|
+
handler: async (_req, next) => next(),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("concatenates middleware across plugins in order", async () => {
|
|
293
|
+
const plugins: ManduPlugin[] = [
|
|
294
|
+
{ name: "p1", hooks: { defineMiddlewareChain: () => [mkMw("a"), mkMw("b")] } },
|
|
295
|
+
{ name: "p2", hooks: { defineMiddlewareChain: () => [mkMw("c")] } },
|
|
296
|
+
];
|
|
297
|
+
const report = await runDefineMiddlewareChain(mkPluginContext(), { plugins });
|
|
298
|
+
expect(report.result.map((m) => m.name)).toEqual(["a", "b", "c"]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("resolvePluginMiddleware returns flattened list", async () => {
|
|
302
|
+
const plugins: ManduPlugin[] = [
|
|
303
|
+
{ name: "p", hooks: { defineMiddlewareChain: () => [mkMw("only")] } },
|
|
304
|
+
];
|
|
305
|
+
const mw = await resolvePluginMiddleware({
|
|
306
|
+
plugins,
|
|
307
|
+
rootDir: "/tmp",
|
|
308
|
+
mode: "production",
|
|
309
|
+
});
|
|
310
|
+
expect(mw.map((m) => m.name)).toEqual(["only"]);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
315
|
+
describe("runner — defineTestTransform pipe", () => {
|
|
316
|
+
it("pipes each plugin's output into the next", async () => {
|
|
317
|
+
const plugins: ManduPlugin[] = [
|
|
318
|
+
{
|
|
319
|
+
name: "upper",
|
|
320
|
+
hooks: {
|
|
321
|
+
defineTestTransform: (ctx) => ctx.source.toUpperCase(),
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: "suffix",
|
|
326
|
+
hooks: {
|
|
327
|
+
defineTestTransform: (ctx) => ctx.source + "!",
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
const report = await runDefineTestTransform(
|
|
332
|
+
{ testFile: "foo.test.ts", source: "hello" },
|
|
333
|
+
{ plugins },
|
|
334
|
+
);
|
|
335
|
+
expect(report.result).toBe("HELLO!");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("preserves source when a plugin throws", async () => {
|
|
339
|
+
const plugins: ManduPlugin[] = [
|
|
340
|
+
{
|
|
341
|
+
name: "bad",
|
|
342
|
+
hooks: {
|
|
343
|
+
defineTestTransform: () => { throw new Error("oops"); },
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "good",
|
|
348
|
+
hooks: {
|
|
349
|
+
defineTestTransform: (ctx) => ctx.source + ":ok",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
const report = await runDefineTestTransform(
|
|
354
|
+
{ testFile: "foo.test.ts", source: "base" },
|
|
355
|
+
{ plugins },
|
|
356
|
+
);
|
|
357
|
+
expect(report.result).toBe("base:ok");
|
|
358
|
+
expect(report.errors).toHaveLength(1);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
363
|
+
describe("runner — async hook timing", () => {
|
|
364
|
+
it("awaits async hooks serially", async () => {
|
|
365
|
+
const log: string[] = [];
|
|
366
|
+
const plugins: ManduPlugin[] = [
|
|
367
|
+
{
|
|
368
|
+
name: "slow",
|
|
369
|
+
hooks: {
|
|
370
|
+
async onRouteRegistered() {
|
|
371
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
372
|
+
log.push("slow");
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "fast",
|
|
378
|
+
hooks: {
|
|
379
|
+
onRouteRegistered: () => {
|
|
380
|
+
log.push("fast");
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
await runOnRouteRegistered(sampleRoute, { plugins });
|
|
386
|
+
expect(log).toEqual(["slow", "fast"]);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
391
|
+
describe("runner — formatHookErrors", () => {
|
|
392
|
+
it("returns null when the report is clean", () => {
|
|
393
|
+
expect(
|
|
394
|
+
formatHookErrors({ result: undefined, errors: [] }),
|
|
395
|
+
).toBeNull();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("formats error rollup", () => {
|
|
399
|
+
const formatted = formatHookErrors({
|
|
400
|
+
result: undefined,
|
|
401
|
+
errors: [
|
|
402
|
+
{ hook: "onRouteRegistered", source: "bad-plugin", error: new Error("boom") },
|
|
403
|
+
],
|
|
404
|
+
});
|
|
405
|
+
expect(formatted).toContain("1 hook failure(s)");
|
|
406
|
+
expect(formatted).toContain("bad-plugin");
|
|
407
|
+
expect(formatted).toContain("boom");
|
|
408
|
+
});
|
|
409
|
+
});
|