@mandujs/core 0.24.0 → 0.25.0
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 +3 -1
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +18 -4
- package/src/client/globals.ts +61 -44
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +64 -0
- package/src/config/validate.ts +35 -0
- package/src/content/collection.ts +506 -0
- package/src/content/frontmatter.ts +189 -0
- package/src/content/generate-types.ts +168 -0
- package/src/content/index.ts +206 -168
- package/src/content/llms-txt.ts +196 -0
- package/src/content/prebuild.test.ts +249 -0
- package/src/content/prebuild.ts +400 -0
- package/src/content/schema.ts +20 -0
- package/src/content/sidebar.ts +212 -0
- package/src/content/slug.ts +110 -0
- package/src/guard/check.ts +5 -2
- package/src/observability/index.ts +37 -18
- package/src/observability/metrics.ts +334 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +104 -4
- package/src/runtime/ssr.ts +118 -1
- package/src/runtime/streaming-ssr.ts +11 -1
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llms.txt Generator (Issue #199)
|
|
3
|
+
*
|
|
4
|
+
* Emits a single plain-text index of collection entries in the
|
|
5
|
+
* `llms.txt` convention (https://llmstxt.org). The output is a
|
|
6
|
+
* deterministic Markdown-ish digest that LLM ingestion pipelines can
|
|
7
|
+
* crawl without having to understand the project's routing or MDX
|
|
8
|
+
* compilation.
|
|
9
|
+
*
|
|
10
|
+
* # Default format
|
|
11
|
+
*
|
|
12
|
+
* ```text
|
|
13
|
+
* # {site.name}
|
|
14
|
+
*
|
|
15
|
+
* ## docs
|
|
16
|
+
* - [Introduction](/docs/intro): Getting started
|
|
17
|
+
* - [CLI](/docs/cli): Command reference
|
|
18
|
+
*
|
|
19
|
+
* ## blog
|
|
20
|
+
* - [Hello world](/blog/hello): First post
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* # `full: true` variant
|
|
24
|
+
*
|
|
25
|
+
* When `full: true` is passed, every entry's body is inlined after
|
|
26
|
+
* its heading — useful for tools that want an offline snapshot of
|
|
27
|
+
* all content. This produces `llms-full.txt` in most conventions;
|
|
28
|
+
* the filename is the caller's responsibility.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Collection, CollectionEntry } from "./collection";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Collection shape accepted by llms.txt — we use a structural subset
|
|
35
|
+
* (just the `all()` method) so callers can pass typed collections
|
|
36
|
+
* (`Collection<{ title: string }>`) without fighting TypeScript's
|
|
37
|
+
* invariance on generic classes. The generator never writes back into
|
|
38
|
+
* the collection, so the type-erasure to `CollectionEntry<unknown>`
|
|
39
|
+
* is safe at runtime — llms.txt reads `data.title`/`data.description`
|
|
40
|
+
* via `unknown` narrowing.
|
|
41
|
+
*/
|
|
42
|
+
// Covariant read-only view of a Collection. We deliberately re-declare
|
|
43
|
+
// the method signature with `unknown` so Collection<{...}> assigns
|
|
44
|
+
// structurally. `bivarianceHack` lets the assignability flow.
|
|
45
|
+
interface CollectionReader {
|
|
46
|
+
all(): Promise<Array<{ slug: string; filePath: string; data: unknown; content: string }>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Entry in the input array — either a Collection or a pre-loaded triple. */
|
|
50
|
+
export type LLMSTxtInput =
|
|
51
|
+
| { name: string; collection: CollectionReader }
|
|
52
|
+
| { name: string; entries: CollectionEntry<unknown>[] };
|
|
53
|
+
|
|
54
|
+
// Re-export the full Collection type so consumers importing the llms
|
|
55
|
+
// types still see the concrete class alongside the reader alias.
|
|
56
|
+
export type { Collection };
|
|
57
|
+
|
|
58
|
+
/** Options controlling llms.txt rendering. */
|
|
59
|
+
export interface GenerateLLMSTxtOptions {
|
|
60
|
+
/**
|
|
61
|
+
* Top-level site title. Rendered as the `#` heading. When omitted,
|
|
62
|
+
* the heading line is skipped so callers can prepend their own.
|
|
63
|
+
*/
|
|
64
|
+
siteName?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Short description rendered below the site heading. Ignored
|
|
67
|
+
* when `siteName` is omitted — the heading anchors the block.
|
|
68
|
+
*/
|
|
69
|
+
description?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Base URL prefix applied to every entry href. Default `/`; pass
|
|
72
|
+
* an absolute origin (e.g. `https://example.com`) to produce an
|
|
73
|
+
* outward-facing llms.txt that third-party crawlers can consume
|
|
74
|
+
* without resolving against the host.
|
|
75
|
+
*/
|
|
76
|
+
basePath?: string;
|
|
77
|
+
/**
|
|
78
|
+
* When true, include each entry's body verbatim under its heading.
|
|
79
|
+
* This produces the `llms-full.txt` variant — significantly larger
|
|
80
|
+
* output, but lets consumers avoid a second fetch per entry.
|
|
81
|
+
*/
|
|
82
|
+
full?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Include entries with `data.draft === true` (default: false).
|
|
85
|
+
* Production sites should leave this off so unpublished content
|
|
86
|
+
* doesn't leak to external crawlers.
|
|
87
|
+
*/
|
|
88
|
+
includeDrafts?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Override the per-entry summary line. Defaults to
|
|
91
|
+
* `entry.data.description ?? ""`. Returning an empty string
|
|
92
|
+
* omits the trailing `: {summary}` tail.
|
|
93
|
+
*/
|
|
94
|
+
getSummary?: (entry: CollectionEntry<unknown>) => string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate an llms.txt document from one or more collections.
|
|
99
|
+
*
|
|
100
|
+
* Accepts both `Collection` instances (loaded internally) and
|
|
101
|
+
* pre-loaded entry arrays so callers can pipe in filtered/transformed
|
|
102
|
+
* data without paying for a second scan.
|
|
103
|
+
*/
|
|
104
|
+
export async function generateLLMSTxt(
|
|
105
|
+
inputs: LLMSTxtInput[],
|
|
106
|
+
options: GenerateLLMSTxtOptions = {}
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
const {
|
|
109
|
+
siteName,
|
|
110
|
+
description,
|
|
111
|
+
basePath = "/",
|
|
112
|
+
full = false,
|
|
113
|
+
includeDrafts = false,
|
|
114
|
+
getSummary,
|
|
115
|
+
} = options;
|
|
116
|
+
|
|
117
|
+
const lines: string[] = [];
|
|
118
|
+
if (siteName) {
|
|
119
|
+
lines.push(`# ${siteName}`);
|
|
120
|
+
if (description) {
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push(`> ${description}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const input of inputs) {
|
|
128
|
+
const entries = await loadInput(input);
|
|
129
|
+
const visible = includeDrafts
|
|
130
|
+
? entries
|
|
131
|
+
: entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
|
|
132
|
+
if (visible.length === 0) continue;
|
|
133
|
+
|
|
134
|
+
// Sort within each collection by slug for deterministic output —
|
|
135
|
+
// llms.txt is often diffed by agents/tools, and a stable order
|
|
136
|
+
// keeps those diffs meaningful.
|
|
137
|
+
const sorted = [...visible].sort((a, b) =>
|
|
138
|
+
a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
lines.push(`## ${input.name}`);
|
|
142
|
+
lines.push("");
|
|
143
|
+
for (const entry of sorted) {
|
|
144
|
+
const title =
|
|
145
|
+
typeof (entry.data as { title?: unknown })?.title === "string"
|
|
146
|
+
? String((entry.data as { title: string }).title)
|
|
147
|
+
: entry.slug || "index";
|
|
148
|
+
const href = joinHref(basePath, input.name, entry.slug);
|
|
149
|
+
const summary = getSummary
|
|
150
|
+
? getSummary(entry)
|
|
151
|
+
: typeof (entry.data as { description?: unknown })?.description === "string"
|
|
152
|
+
? String((entry.data as { description: string }).description)
|
|
153
|
+
: "";
|
|
154
|
+
const tail = summary ? `: ${summary}` : "";
|
|
155
|
+
lines.push(`- [${title}](${href})${tail}`);
|
|
156
|
+
if (full) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(entry.content);
|
|
159
|
+
lines.push("");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Trim the trailing blank line — the "ends in newline" convention
|
|
166
|
+
// is handled by the single final `\n` below so we don't accumulate
|
|
167
|
+
// extra blank tail lines across collections.
|
|
168
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
169
|
+
return lines.join("\n") + "\n";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function loadInput(input: LLMSTxtInput): Promise<CollectionEntry<unknown>[]> {
|
|
173
|
+
if ("entries" in input) return input.entries;
|
|
174
|
+
// CollectionReader.all() yields the same shape as
|
|
175
|
+
// CollectionEntry<unknown>[] — the cast here is a trivial widening.
|
|
176
|
+
const entries = await input.collection.all();
|
|
177
|
+
return entries as CollectionEntry<unknown>[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function joinHref(base: string, collectionName: string, slug: string): string {
|
|
181
|
+
const parts = [collectionName, slug].filter((x) => x !== "" && x !== "/");
|
|
182
|
+
const tail = parts.join("/").replace(/\/+/g, "/");
|
|
183
|
+
if (base.startsWith("http")) {
|
|
184
|
+
// Preserve the `//` after the protocol — we only collapse slashes
|
|
185
|
+
// in the path portion, so `https://example.com/docs/foo` survives
|
|
186
|
+
// intact instead of becoming `https:/example.com/docs/foo`.
|
|
187
|
+
const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
188
|
+
return tail ? `${trimmedBase}/${tail}` : trimmedBase;
|
|
189
|
+
}
|
|
190
|
+
if (base === "" || base === "/") {
|
|
191
|
+
return `/${tail}`.replace(/\/+/g, "/");
|
|
192
|
+
}
|
|
193
|
+
const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
194
|
+
const leading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
195
|
+
return `${leading}/${tail}`.replace(/\/+/g, "/");
|
|
196
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `content/prebuild.ts` (Issue #196).
|
|
3
|
+
*
|
|
4
|
+
* We exercise:
|
|
5
|
+
* - `discoverPrebuildScripts` globbing + sort determinism
|
|
6
|
+
* - `shouldAutoPrebuild` activation policy (content/ OR scripts/prebuild-*.ts)
|
|
7
|
+
* - `runPrebuildScripts` with an injected spawn hook (no actual subprocess)
|
|
8
|
+
* covering the success path, per-script fail-fast, timeout surface, and
|
|
9
|
+
* the empty-discovery no-op fast path.
|
|
10
|
+
*
|
|
11
|
+
* Spawning a real `bun` subprocess is intentionally avoided — those paths
|
|
12
|
+
* belong in the CLI-level integration test. At this layer the Bun runtime
|
|
13
|
+
* is mocked so the test doubles as a correctness guarantee for any Node
|
|
14
|
+
* environment that later injects its own spawn hook.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
discoverPrebuildScripts,
|
|
24
|
+
shouldAutoPrebuild,
|
|
25
|
+
runPrebuildScripts,
|
|
26
|
+
PrebuildError,
|
|
27
|
+
type SpawnHook,
|
|
28
|
+
} from "./prebuild";
|
|
29
|
+
|
|
30
|
+
const PREFIX = path.join(os.tmpdir(), "mandu-prebuild-test-");
|
|
31
|
+
|
|
32
|
+
function mktmp(prefix = ""): string {
|
|
33
|
+
return fs.mkdtempSync(PREFIX + prefix);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeFile(dir: string, rel: string, content = "// stub\n"): string {
|
|
37
|
+
const abs = path.join(dir, rel);
|
|
38
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
39
|
+
fs.writeFileSync(abs, content);
|
|
40
|
+
return abs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// discoverPrebuildScripts
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe("discoverPrebuildScripts", () => {
|
|
48
|
+
let dir = "";
|
|
49
|
+
beforeEach(() => { dir = mktmp("discover-"); });
|
|
50
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
51
|
+
|
|
52
|
+
it("returns empty when scripts/ does not exist", () => {
|
|
53
|
+
expect(discoverPrebuildScripts(dir)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns empty when scripts/ exists but has no prebuild files", () => {
|
|
57
|
+
writeFile(dir, "scripts/build.ts");
|
|
58
|
+
writeFile(dir, "scripts/README.md");
|
|
59
|
+
expect(discoverPrebuildScripts(dir)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("finds prebuild-<name>.ts files", () => {
|
|
63
|
+
writeFile(dir, "scripts/prebuild-docs.ts");
|
|
64
|
+
writeFile(dir, "scripts/prebuild-seo.ts");
|
|
65
|
+
const found = discoverPrebuildScripts(dir);
|
|
66
|
+
expect(found).toHaveLength(2);
|
|
67
|
+
expect(found[0]).toContain("prebuild-docs.ts");
|
|
68
|
+
expect(found[1]).toContain("prebuild-seo.ts");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("sorts lexicographically so numeric prefix controls ordering", () => {
|
|
72
|
+
writeFile(dir, "scripts/prebuild-20-second.ts");
|
|
73
|
+
writeFile(dir, "scripts/prebuild-10-first.ts");
|
|
74
|
+
writeFile(dir, "scripts/prebuild-99-third.ts");
|
|
75
|
+
const found = discoverPrebuildScripts(dir).map((p) => path.basename(p));
|
|
76
|
+
expect(found).toEqual([
|
|
77
|
+
"prebuild-10-first.ts",
|
|
78
|
+
"prebuild-20-second.ts",
|
|
79
|
+
"prebuild-99-third.ts",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts .tsx, .js, .mjs extensions", () => {
|
|
84
|
+
writeFile(dir, "scripts/prebuild-a.tsx");
|
|
85
|
+
writeFile(dir, "scripts/prebuild-b.js");
|
|
86
|
+
writeFile(dir, "scripts/prebuild-c.mjs");
|
|
87
|
+
const found = discoverPrebuildScripts(dir).map((p) => path.basename(p));
|
|
88
|
+
expect(found).toEqual(["prebuild-a.tsx", "prebuild-b.js", "prebuild-c.mjs"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects non-prebuild filenames even if in scripts/", () => {
|
|
92
|
+
writeFile(dir, "scripts/my-prebuild.ts"); // doesn't start with prebuild
|
|
93
|
+
writeFile(dir, "scripts/prebuild.py"); // wrong extension
|
|
94
|
+
writeFile(dir, "scripts/prebuild-valid.ts");
|
|
95
|
+
const found = discoverPrebuildScripts(dir).map((p) => path.basename(p));
|
|
96
|
+
expect(found).toEqual(["prebuild-valid.ts"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("honors custom scriptsDir", () => {
|
|
100
|
+
writeFile(dir, "custom/prebuild-1.ts");
|
|
101
|
+
expect(discoverPrebuildScripts(dir, "custom")).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// shouldAutoPrebuild
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("shouldAutoPrebuild", () => {
|
|
110
|
+
let dir = "";
|
|
111
|
+
beforeEach(() => { dir = mktmp("policy-"); });
|
|
112
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
113
|
+
|
|
114
|
+
it("false when neither content/ nor prebuild scripts exist", () => {
|
|
115
|
+
expect(shouldAutoPrebuild(dir)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("true when content/ directory exists (even if empty)", () => {
|
|
119
|
+
fs.mkdirSync(path.join(dir, "content"));
|
|
120
|
+
expect(shouldAutoPrebuild(dir)).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("true when a prebuild script exists but content/ does not", () => {
|
|
124
|
+
writeFile(dir, "scripts/prebuild-docs.ts");
|
|
125
|
+
expect(shouldAutoPrebuild(dir)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("false when content/ is a file, not a directory", () => {
|
|
129
|
+
fs.writeFileSync(path.join(dir, "content"), "not a dir");
|
|
130
|
+
expect(shouldAutoPrebuild(dir)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// runPrebuildScripts with mock spawn
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function makeMockSpawn(
|
|
139
|
+
policy: (scriptPath: string) => { exitCode: number | null; durationMs: number } | Promise<{ exitCode: number | null; durationMs: number }>,
|
|
140
|
+
): SpawnHook & { calls: Array<{ scriptPath: string; cwd: string; timeoutMs: number }> } {
|
|
141
|
+
const calls: Array<{ scriptPath: string; cwd: string; timeoutMs: number }> = [];
|
|
142
|
+
const hook: SpawnHook = async (args) => {
|
|
143
|
+
calls.push(args);
|
|
144
|
+
return await policy(args.scriptPath);
|
|
145
|
+
};
|
|
146
|
+
return Object.assign(hook, { calls });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("runPrebuildScripts", () => {
|
|
150
|
+
let dir = "";
|
|
151
|
+
beforeEach(() => { dir = mktmp("run-"); });
|
|
152
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
153
|
+
|
|
154
|
+
it("returns { ran: 0 } when no scripts exist — silent no-op", async () => {
|
|
155
|
+
const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 5 }));
|
|
156
|
+
const result = await runPrebuildScripts({ rootDir: dir, spawn });
|
|
157
|
+
expect(result.ran).toBe(0);
|
|
158
|
+
expect(spawn.calls).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("runs discovered scripts in order", async () => {
|
|
162
|
+
writeFile(dir, "scripts/prebuild-b.ts");
|
|
163
|
+
writeFile(dir, "scripts/prebuild-a.ts");
|
|
164
|
+
const order: string[] = [];
|
|
165
|
+
const spawn = makeMockSpawn(async (p) => {
|
|
166
|
+
order.push(path.basename(p));
|
|
167
|
+
return { exitCode: 0, durationMs: 1 };
|
|
168
|
+
});
|
|
169
|
+
const result = await runPrebuildScripts({ rootDir: dir, spawn });
|
|
170
|
+
expect(result.ran).toBe(2);
|
|
171
|
+
expect(order).toEqual(["prebuild-a.ts", "prebuild-b.ts"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("throws PrebuildError with exitCode on first non-zero exit", async () => {
|
|
175
|
+
writeFile(dir, "scripts/prebuild-1.ts");
|
|
176
|
+
writeFile(dir, "scripts/prebuild-2.ts");
|
|
177
|
+
const spawn = makeMockSpawn((p) => ({
|
|
178
|
+
exitCode: path.basename(p) === "prebuild-1.ts" ? 3 : 0,
|
|
179
|
+
durationMs: 1,
|
|
180
|
+
}));
|
|
181
|
+
let caught: unknown;
|
|
182
|
+
try {
|
|
183
|
+
await runPrebuildScripts({ rootDir: dir, spawn });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
caught = e;
|
|
186
|
+
}
|
|
187
|
+
expect(caught).toBeInstanceOf(PrebuildError);
|
|
188
|
+
const err = caught as PrebuildError;
|
|
189
|
+
expect(err.exitCode).toBe(3);
|
|
190
|
+
expect(err.scriptPath).toContain("prebuild-1.ts");
|
|
191
|
+
// prebuild-2.ts must NOT have been invoked (fail-fast chain semantics).
|
|
192
|
+
expect(spawn.calls).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("wraps non-PrebuildError spawn rejections", async () => {
|
|
196
|
+
writeFile(dir, "scripts/prebuild.ts");
|
|
197
|
+
const spawn: SpawnHook = async () => {
|
|
198
|
+
throw new Error("spawn ENOENT");
|
|
199
|
+
};
|
|
200
|
+
let caught: unknown;
|
|
201
|
+
try {
|
|
202
|
+
await runPrebuildScripts({ rootDir: dir, spawn });
|
|
203
|
+
} catch (e) {
|
|
204
|
+
caught = e;
|
|
205
|
+
}
|
|
206
|
+
expect(caught).toBeInstanceOf(PrebuildError);
|
|
207
|
+
expect((caught as PrebuildError).message).toContain("spawn ENOENT");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("invokes onStart / onFinish callbacks for each script", async () => {
|
|
211
|
+
writeFile(dir, "scripts/prebuild-a.ts");
|
|
212
|
+
writeFile(dir, "scripts/prebuild-b.ts");
|
|
213
|
+
const starts: string[] = [];
|
|
214
|
+
const finishes: Array<{ name: string; code: number | null }> = [];
|
|
215
|
+
await runPrebuildScripts({
|
|
216
|
+
rootDir: dir,
|
|
217
|
+
spawn: async () => ({ exitCode: 0, durationMs: 1 }),
|
|
218
|
+
onStart: (p, i, total) => {
|
|
219
|
+
starts.push(`${i + 1}/${total}:${path.basename(p)}`);
|
|
220
|
+
},
|
|
221
|
+
onFinish: (r) => {
|
|
222
|
+
finishes.push({ name: path.basename(r.scriptPath), code: r.exitCode });
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(starts).toEqual(["1/2:prebuild-a.ts", "2/2:prebuild-b.ts"]);
|
|
226
|
+
expect(finishes).toEqual([
|
|
227
|
+
{ name: "prebuild-a.ts", code: 0 },
|
|
228
|
+
{ name: "prebuild-b.ts", code: 0 },
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("threads timeoutMs through to the spawn hook", async () => {
|
|
233
|
+
writeFile(dir, "scripts/prebuild-1.ts");
|
|
234
|
+
const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 1 }));
|
|
235
|
+
await runPrebuildScripts({
|
|
236
|
+
rootDir: dir,
|
|
237
|
+
spawn,
|
|
238
|
+
timeoutMs: 5_000,
|
|
239
|
+
});
|
|
240
|
+
expect(spawn.calls[0].timeoutMs).toBe(5_000);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("uses default 2-minute timeout when not overridden", async () => {
|
|
244
|
+
writeFile(dir, "scripts/prebuild-1.ts");
|
|
245
|
+
const spawn = makeMockSpawn(() => ({ exitCode: 0, durationMs: 1 }));
|
|
246
|
+
await runPrebuildScripts({ rootDir: dir, spawn });
|
|
247
|
+
expect(spawn.calls[0].timeoutMs).toBe(2 * 60 * 1000);
|
|
248
|
+
});
|
|
249
|
+
});
|