@mintlify/cli 4.0.1466 → 4.0.1468

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.
Files changed (40) hide show
  1. package/__test__/mintTestFilePreview.test.ts +35 -0
  2. package/__test__/mintTestOutput.test.ts +194 -0
  3. package/__test__/runHistory.test.ts +164 -0
  4. package/bin/agent-harness/agentPreflight.js +24 -0
  5. package/bin/agent-harness/buildTaskPrompt.js +17 -7
  6. package/bin/agent-harness/countReport.js +10 -0
  7. package/bin/agent-harness/executeCodeBlocks.js +53 -10
  8. package/bin/agent-harness/generateTestCode.js +58 -43
  9. package/bin/agent-harness/index.js +14 -5
  10. package/bin/agent-harness/manifest.js +113 -0
  11. package/bin/agent-harness/runHistory.js +252 -0
  12. package/bin/agent-harness/setupFolders.js +20 -6
  13. package/bin/agent-harness/tasks/checkTestability.js +7 -5
  14. package/bin/agent-harness/types.js +33 -2
  15. package/bin/constants.js +3 -3
  16. package/bin/mintTest.js +14 -12
  17. package/bin/mintTestFilePreview.js +39 -0
  18. package/bin/mintTestText.js +31 -0
  19. package/bin/mintTestUi.js +295 -105
  20. package/bin/status.js +26 -0
  21. package/bin/tsconfig.build.tsbuildinfo +1 -1
  22. package/package.json +2 -2
  23. package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +15 -9
  24. package/src/agent-harness/agentPreflight.ts +13 -0
  25. package/src/agent-harness/buildTaskPrompt.ts +18 -7
  26. package/src/agent-harness/countReport.ts +17 -0
  27. package/src/agent-harness/executeCodeBlocks.ts +67 -1
  28. package/src/agent-harness/generateTestCode.ts +81 -51
  29. package/src/agent-harness/index.ts +20 -5
  30. package/src/agent-harness/manifest.ts +116 -0
  31. package/src/agent-harness/runHistory.ts +250 -0
  32. package/src/agent-harness/setupFolders.ts +19 -5
  33. package/src/agent-harness/tasks/checkTestability.ts +15 -6
  34. package/src/agent-harness/types.ts +81 -5
  35. package/src/constants.ts +6 -2
  36. package/src/mintTest.tsx +15 -11
  37. package/src/mintTestFilePreview.ts +32 -0
  38. package/src/mintTestText.ts +35 -0
  39. package/src/mintTestUi.tsx +586 -224
  40. package/src/status.tsx +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/cli",
3
- "version": "4.0.1466",
3
+ "version": "4.0.1468",
4
4
  "description": "The Mintlify CLI",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -105,5 +105,5 @@
105
105
  "vitest": "2.1.9",
106
106
  "vitest-mock-process": "1.0.4"
107
107
  },
108
- "gitHead": "ce376b725af9861a1342801490b421ba3e414136"
108
+ "gitHead": "8baca715ce0789cc49c2d73efad90254b89411ff"
109
109
  }
@@ -43,15 +43,21 @@ flowchart TD
43
43
 
44
44
  The harness exports five types. Status unions and nested block, environment, summary, command, and task fields live on the contract that owns them.
45
45
 
46
- | Exported type | Contract |
47
- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
48
- | `HarnessAgent` | The supported runner names, `claude` and `codex`. |
49
- | `AgentRunner` | One `prompt(prompt)` method returning the final response text. `ClaudeRunner` and `CodexRunner` implement it. |
50
- | `CodeBlockCollection` | The docs root and fenced block locations grouped by markdown file. `collectCodeBlocks` returns it. |
51
- | `GeneratedTestManifest` | The inferred output of the runtime `mint-test.json` schema, including generated files, block dispositions, environment needs, and commands. |
52
- | `CodeTestReport` | The complete persisted `report.json` shape. Task, block, command, environment, summary, and status fields are nested here. |
53
-
54
- `GeneratedTestManifest` is inferred from the runtime schema in `packages/cli/src/agent-harness/types.ts`, so validation and TypeScript cannot drift apart. Contract changes still require a schema test and an update to this table.
46
+ | Exported type | Contract |
47
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | `HarnessAgent` | The supported runner names, `claude` and `codex`. |
49
+ | `AgentRunner` | One `prompt(prompt)` method returning the final response text. `ClaudeRunner` and `CodexRunner` implement it. |
50
+ | `CodeBlockCollection` | The docs root and fenced block locations grouped by markdown file. `collectCodeBlocks` returns it. |
51
+ | `GeneratedTestManifest` | The normalized output of the runtime `mint-test.json` schema: generated files plus setup and test commands. Command ids are optional in the file; `manifestSchema` assigns `setup-N` / `test-N` when an agent omits them. |
52
+ | `CodeTestReport` | The complete persisted `report.json` shape. Task, block, command, environment, summary, and status fields are nested here. |
53
+
54
+ `GeneratedTestManifest` is produced by the runtime schema in `packages/cli/src/agent-harness/types.ts` (`manifestFileSchema.transform(assignCommandIds)`), so validation and TypeScript cannot drift apart. Contract changes still require a schema test and an update to this table.
55
+
56
+ `packages/cli/src/agent-harness/manifest.ts` owns reading the manifest. It turns a missing file, invalid JSON, or a schema mismatch into one actionable message per problem; that message is what the UI, the non-interactive summary, and `report.json` show for the page. Each task directory also keeps `mint-test-agent.md` (the agent's final summary per attempt) and, after a failed generation, `mint-test-error.txt` (every attempt's failure reason).
57
+
58
+ `runCodeTests` loads each selected agent SDK before any page is checked (`agentPreflight.ts`), so a missing optional dependency fails the run once with an install hint instead of failing every page minutes later.
59
+
60
+ `runCodeTests` runs the pipeline in order: `setupFolders` picks the run id, creates the run directory, and builds one task per agent and page without creating task directories; `checkTestability` asks a small model whether each page is testable, running from the run directory, and only once a page is proven testable creates its task directory and writes `mint-test-check.json` there; `scaffoldTaskDirectories` then adds `tests/` and `package.json` to those same tasks; `generateTestCode` and `executeCodeBlocks` skip every other task. A page that is not testable leaves nothing on disk. A resumed run reuses the check file for testable pages, re-checks the others, and re-applies the scaffold, which is idempotent.
55
61
 
56
62
  `runCodeTests` declares its input object and progress events on the function. `executeCodeTestCommand` does the same for command input. Neither API has a separate options, request, progress, preflight, command-runner, or command-result type to keep in sync.
57
63
 
@@ -0,0 +1,13 @@
1
+ import { loadClaudeAgentSdk, loadCodexSdk } from './loadAgentSdk.js';
2
+ import type { HarnessAgent } from './types.js';
3
+
4
+ /**
5
+ * Fail fast, before any page is checked, when an agent SDK is not installed. Without this every
6
+ * page reports the same missing-dependency error minutes later as "test could not be generated".
7
+ */
8
+ export async function ensureAgentSdks(agents: Iterable<HarnessAgent>): Promise<void> {
9
+ for (const agent of new Set(agents)) {
10
+ if (agent === 'claude') await loadClaudeAgentSdk();
11
+ else await loadCodexSdk();
12
+ }
13
+ }
@@ -1,9 +1,11 @@
1
1
  import path from 'node:path';
2
2
 
3
+ import { SAFE_EXECUTABLES } from './manifest.js';
4
+
3
5
  export const SYSTEM_PROMPT = `# Mint test generator
4
6
 
5
- You will be provided a markdown page guide that contians codeblocks. Follow the guide and implement
6
- the code as instrucuted by the guide into a given output directory.
7
+ You will be given a markdown documentation page that contains code blocks. Follow the page and
8
+ implement the code as the page instructs, inside the given output directory.
7
9
 
8
10
  Once finished, create a mint-test.json file in the current working directory with this shape:
9
11
 
@@ -13,12 +15,14 @@ Once finished, create a mint-test.json file in the current working directory wit
13
15
  "setupCommands": [],
14
16
  "testCommands": [
15
17
  {
18
+ "id": "node-tests",
16
19
  "executable": "node",
17
- "args": ["--test", "tests/"],
20
+ "args": ["--test"],
18
21
  "cwd": ".",
19
22
  "timeoutMs": 120000
20
23
  },
21
24
  {
25
+ "id": "python-tests",
22
26
  "executable": "python3",
23
27
  "args": ["-m", "unittest", "discover", "-s", "tests"],
24
28
  "cwd": ".",
@@ -27,11 +31,18 @@ Once finished, create a mint-test.json file in the current working directory wit
27
31
  ]
28
32
  }
29
33
 
30
- Command cwd values must be relative to the current working directory. Finish with a short plain-text summary.
34
+ Manifest rules:
35
+ - "generatedFiles" lists every file you wrote, as paths relative to the current working directory. Each one must exist.
36
+ - Every command has a unique "id" (letters, digits, dashes). Command "cwd" values are relative to the current working directory.
37
+ - "executable" must be one of: ${[...SAFE_EXECUTABLES].join(', ')}, or a "./" script inside the current working directory. Do not use a shell, pipes, or "&&".
38
+ - Do not add any other keys to the manifest.
39
+
40
+ Finish with a short plain-text summary of what you tested and anything you could not test.
31
41
 
32
42
  Rules:
33
43
  - Read only the page named in the task. You may also read docs.json and an OpenAPI file referenced by that page.
34
- - The output directory is already scaffolded: package.json exists with "npm test" wired to "node --test tests/", and a tests/ directory is ready. Do not recreate or rewrite this scaffolding.
44
+ - The output directory is already scaffolded: package.json exists with "npm test" wired to "node --test", and a tests/ directory is ready. Do not recreate or rewrite this scaffolding.
45
+ - Run JavaScript tests with "node" and args ["--test"] from the task directory (no directory argument: on Node 21+ a path like "tests/" is treated as a glob and finds nothing). Node discovers tests/*.test.js on its own.
35
46
  - Write JavaScript and TypeScript tests as ES modules named tests/*.test.js using the Node built-in test runner (node:test and node:assert). Write Python tests as tests/test_*.py using the standard library unittest module, run with python3 -m unittest discover -s tests.
36
47
  - Prefer the standard library over installing dependencies; add a setup command only when a test truly cannot run without one.
37
48
  - Never write README, docs, or explanation files.
@@ -73,10 +84,10 @@ export const CHECK_JSON_INSTRUCTION = `
73
84
  Respond with only this JSON object and nothing else:
74
85
  { "version": 1, "testable": false, "reason": "expected output, not runnable code" }`;
75
86
 
76
- export function buildCheckPrompt(file: string, fileContnet: string): string {
87
+ export function buildCheckPrompt(file: string, fileContent: string): string {
77
88
  return `Report if the file at filePath ${JSON.stringify(file)} is testable
78
89
 
79
90
  <file>
80
- ${fileContnet}
91
+ ${fileContent}
81
92
  </file>`;
82
93
  }
@@ -0,0 +1,17 @@
1
+ import type { TaskResult } from './types.js';
2
+
3
+ export interface ReportCounts {
4
+ passed: number;
5
+ failed: number;
6
+ cancelled: number;
7
+ agentErrors: number;
8
+ }
9
+
10
+ export function countReport(tasks: TaskResult[]): ReportCounts {
11
+ const counts: ReportCounts = { passed: 0, failed: 0, cancelled: 0, agentErrors: 0 };
12
+ for (const task of tasks) {
13
+ if (task.status === 'agent_error') counts.agentErrors++;
14
+ else counts[task.status]++;
15
+ }
16
+ return counts;
17
+ }
@@ -52,6 +52,55 @@ function outputCollector(): { append: (chunk: Buffer | string) => void; value: (
52
52
  };
53
53
  }
54
54
 
55
+ const MAX_FAILURE_LINE_LENGTH = 200;
56
+ const TAP_SUMMARY_PATTERN =
57
+ /^#\s*(tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b|^1\.\.\d+$|^TAP version/;
58
+ const ERROR_LINE_PATTERN = /\b(?:[A-Za-z]*Error|Traceback|FAILED|ModuleNotFoundError)\b/;
59
+ const TAP_METADATA_PATTERN = /^(name|code|failureType|location|stack)\s*:/;
60
+
61
+ /**
62
+ * The most useful single line from a failed command's output: the first thrown error, else the
63
+ * first failing TAP assertion, else the last line that is not a TAP summary.
64
+ */
65
+ export function pickFailureLine(text: string): string {
66
+ const lines = text
67
+ .split(/\r?\n/)
68
+ .map((raw) => raw.trim())
69
+ .filter((raw) => raw.length > 0 && !raw.startsWith('[mint test:'))
70
+ .map((raw) => ({ raw, text: raw.replace(/^#\s?/, '').trim() }))
71
+ .filter((line) => line.text.length > 0);
72
+ const thrown = lines.find(
73
+ ({ text: candidate }) =>
74
+ ERROR_LINE_PATTERN.test(candidate) &&
75
+ !candidate.startsWith('at ') &&
76
+ !/^(ok|Traceback)\b/.test(candidate) &&
77
+ !TAP_METADATA_PATTERN.test(candidate)
78
+ );
79
+ const assertion = lines.find(({ text: candidate }) => candidate.startsWith('not ok'));
80
+ const informative = lines.filter(({ raw }) => !TAP_SUMMARY_PATTERN.test(raw));
81
+ const line =
82
+ thrown?.text ??
83
+ assertion?.text ??
84
+ informative[informative.length - 1]?.text ??
85
+ lines[lines.length - 1]?.text ??
86
+ '';
87
+ return line.length > MAX_FAILURE_LINE_LENGTH
88
+ ? `${line.slice(0, MAX_FAILURE_LINE_LENGTH - 1)}…`
89
+ : line;
90
+ }
91
+
92
+ /** One line explaining the first command that did not pass, for the UI and the report. */
93
+ export function summarizeCommandFailure(commands: CommandResult[]): string | undefined {
94
+ const failed = commands.find((command) => command.status !== 'passed');
95
+ if (!failed) return undefined;
96
+ const invocation = [failed.executable, ...failed.args].join(' ');
97
+ const reason =
98
+ failed.error ??
99
+ (failed.exitCode === null ? 'did not exit cleanly' : `exited with code ${failed.exitCode}`);
100
+ const detail = pickFailureLine(failed.stderr) || pickFailureLine(failed.stdout);
101
+ return `${failed.phase} command \`${invocation}\` ${reason}${detail ? `: ${detail}` : ''}`;
102
+ }
103
+
55
104
  function unrunCommandResult(
56
105
  command: ManifestCommand,
57
106
  phase: 'setup' | 'test',
@@ -184,11 +233,17 @@ export async function executeCodeBlocks({
184
233
  onTaskUpdate: (update: TaskUpdate) => void;
185
234
  }): Promise<TaskResult[]> {
186
235
  return mapWithConcurrency(tasks, concurrency, async (task) => {
236
+ const generatedFiles =
237
+ task.manifest?.generatedFiles.map((file) => path.resolve(task.directory, file)) ??
238
+ task.generatedFiles ??
239
+ [];
187
240
  const base = {
188
241
  id: task.id,
189
242
  agent: task.agent,
190
243
  file: task.file,
191
244
  directory: task.directory,
245
+ generatedFiles,
246
+ testable: task.testable === true,
192
247
  attempts: task.attempts,
193
248
  tokens: task.tokens,
194
249
  };
@@ -199,6 +254,8 @@ export async function executeCodeBlocks({
199
254
  id: task.id,
200
255
  agent: task.agent,
201
256
  file: task.file,
257
+ directory: task.directory,
258
+ generatedFiles,
202
259
  phase: 'not_testable',
203
260
  tokens: task.tokens,
204
261
  });
@@ -209,8 +266,11 @@ export async function executeCodeBlocks({
209
266
  id: task.id,
210
267
  agent: task.agent,
211
268
  file: task.file,
269
+ directory: task.directory,
270
+ generatedFiles,
212
271
  phase: status,
213
272
  tokens: task.tokens,
273
+ error: task.error,
214
274
  });
215
275
  return {
216
276
  ...base,
@@ -223,6 +283,8 @@ export async function executeCodeBlocks({
223
283
  id: task.id,
224
284
  agent: task.agent,
225
285
  file: task.file,
286
+ directory: task.directory,
287
+ generatedFiles,
226
288
  phase: 'running',
227
289
  tokens: task.tokens,
228
290
  });
@@ -273,13 +335,17 @@ export async function executeCodeBlocks({
273
335
  FAILURE_PRECEDENCE.find((candidate) =>
274
336
  commands.some((command) => command.status === candidate)
275
337
  ) ?? 'passed';
338
+ const error = status === 'passed' ? undefined : summarizeCommandFailure(commands);
276
339
  onTaskUpdate({
277
340
  id: task.id,
278
341
  agent: task.agent,
279
342
  file: task.file,
343
+ directory: task.directory,
344
+ generatedFiles,
280
345
  phase: status,
281
346
  tokens: task.tokens,
347
+ ...(error ? { error } : {}),
282
348
  });
283
- return { ...base, status, commands };
349
+ return { ...base, status, ...(error ? { error } : {}), commands };
284
350
  });
285
351
  }
@@ -3,45 +3,19 @@ import path from 'node:path';
3
3
 
4
4
  import { SYSTEM_PROMPT, buildTaskPrompt } from './buildTaskPrompt.js';
5
5
  import { loadClaudeAgentSdk, loadCodexSdk } from './loadAgentSdk.js';
6
+ import {
7
+ MANIFEST_FILENAME,
8
+ SAFE_EXECUTABLES,
9
+ appendAgentReply,
10
+ readManifest,
11
+ writeErrorDetails,
12
+ } from './manifest.js';
6
13
  import { mapWithConcurrency } from './mapWithConcurrency.js';
7
14
  import { checkFileTestability } from './tasks/checkTestability.js';
8
- import {
9
- manifestSchema,
10
- type GeneratedTask,
11
- type GeneratedTestManifest,
12
- type TaskUpdate,
13
- } from './types.js';
15
+ import type { GeneratedTask, GeneratedTestManifest, TaskUpdate } from './types.js';
14
16
 
15
- const MANIFEST_FILENAME = 'mint-test.json';
16
- const SAFE_EXECUTABLES = new Set([
17
- 'bun',
18
- 'bunx',
19
- 'bundle',
20
- 'cargo',
21
- 'composer',
22
- 'deno',
23
- 'dotnet',
24
- 'go',
25
- 'gradle',
26
- 'java',
27
- 'javac',
28
- 'mvn',
29
- 'node',
30
- 'npm',
31
- 'npx',
32
- 'php',
33
- 'pip',
34
- 'pip3',
35
- 'pnpm',
36
- 'python',
37
- 'python3',
38
- 'pytest',
39
- 'ruby',
40
- 'rustc',
41
- 'uv',
42
- 'vitest',
43
- 'yarn',
44
- ]);
17
+ const SCAFFOLD_FILENAMES = new Set([MANIFEST_FILENAME, 'package.json']);
18
+ const IGNORED_GENERATED_DIRECTORIES = new Set(['.git', 'node_modules']);
45
19
 
46
20
  async function promptClaude({
47
21
  prompt,
@@ -187,9 +161,7 @@ async function validateManifest(
187
161
 
188
162
  async function readValidManifest(directory: string): Promise<GeneratedTestManifest | null> {
189
163
  try {
190
- const manifest = manifestSchema.parse(
191
- JSON.parse(await fs.readFile(path.join(directory, MANIFEST_FILENAME), 'utf8'))
192
- );
164
+ const manifest = await readManifest(directory);
193
165
  const errors = await validateManifest(manifest, directory);
194
166
  return errors.length === 0 ? manifest : null;
195
167
  } catch {
@@ -197,14 +169,41 @@ async function readValidManifest(directory: string): Promise<GeneratedTestManife
197
169
  }
198
170
  }
199
171
 
172
+ async function discoverGeneratedFiles(directory: string): Promise<string[]> {
173
+ const realDirectory = await fs.realpath(directory);
174
+ const files: string[] = [];
175
+
176
+ async function walk(currentDirectory: string): Promise<void> {
177
+ const entries = await fs.readdir(currentDirectory, { withFileTypes: true }).catch(() => []);
178
+ for (const entry of entries) {
179
+ if (files.length >= 1_000) return;
180
+ if (currentDirectory === directory && SCAFFOLD_FILENAMES.has(entry.name)) continue;
181
+ const candidate = path.join(currentDirectory, entry.name);
182
+ if (entry.isDirectory()) {
183
+ if (IGNORED_GENERATED_DIRECTORIES.has(entry.name)) continue;
184
+ await walk(candidate);
185
+ continue;
186
+ }
187
+ if (!entry.isFile()) continue;
188
+ const realPath = await fs.realpath(candidate).catch(() => undefined);
189
+ if (realPath && isPathInside(realDirectory, realPath)) files.push(path.resolve(candidate));
190
+ }
191
+ }
192
+
193
+ await walk(directory);
194
+ return files.sort((left, right) => left.localeCompare(right));
195
+ }
196
+
200
197
  export const CHECK_CONCURRENCY = 20;
201
198
 
202
199
  export async function checkTestability({
203
200
  tasks,
201
+ runDirectory,
204
202
  signal,
205
203
  onTaskUpdate,
206
204
  }: {
207
205
  tasks: GeneratedTask[];
206
+ runDirectory: string;
208
207
  signal: AbortSignal;
209
208
  onTaskUpdate: (update: TaskUpdate) => void;
210
209
  }): Promise<GeneratedTask[]> {
@@ -218,6 +217,7 @@ export async function checkTestability({
218
217
  return mapWithConcurrency(folderOrdered, CHECK_CONCURRENCY, checkFileTestability, {
219
218
  signal,
220
219
  onTaskUpdate,
220
+ runDirectory,
221
221
  });
222
222
  }
223
223
 
@@ -238,15 +238,34 @@ export async function generateTestCode({
238
238
  }): Promise<GeneratedTask[]> {
239
239
  const generateTask = async (task: GeneratedTask): Promise<GeneratedTask> => {
240
240
  if (task.error || !task.testable) return task;
241
- const emit = (phase: TaskUpdate['phase'], tokens: number) =>
242
- onTaskUpdate({ id: task.id, agent: task.agent, file: task.file, phase, tokens });
241
+ const emit = (
242
+ phase: TaskUpdate['phase'],
243
+ tokens: number,
244
+ generatedFiles: string[] = [],
245
+ error?: string
246
+ ) =>
247
+ onTaskUpdate({
248
+ id: task.id,
249
+ agent: task.agent,
250
+ file: task.file,
251
+ directory: task.directory,
252
+ generatedFiles,
253
+ phase,
254
+ tokens,
255
+ ...(error ? { error } : {}),
256
+ });
243
257
  let attempts = task.attempts;
244
258
  let tokens = task.tokens;
245
259
  let lastError = 'run cancelled';
260
+ const attemptErrors: string[] = [];
246
261
 
247
262
  const existing = await readValidManifest(task.directory);
248
263
  if (existing) {
249
- emit('generated', tokens);
264
+ emit(
265
+ 'generated',
266
+ tokens,
267
+ existing.generatedFiles.map((file) => path.resolve(task.directory, file))
268
+ );
250
269
  return { ...task, manifest: existing };
251
270
  }
252
271
 
@@ -274,24 +293,35 @@ export async function generateTestCode({
274
293
  signal,
275
294
  });
276
295
  tokens += reply.tokens;
277
- const manifest = manifestSchema.parse(
278
- JSON.parse(await fs.readFile(path.join(task.directory, MANIFEST_FILENAME), 'utf8'))
279
- );
296
+ await appendAgentReply(task.directory, attempt, reply.result);
297
+ const manifest = await readManifest(task.directory);
280
298
  const errors = await validateManifest(manifest, task.directory);
281
- if (errors.length > 0) throw new Error(errors.join('\n'));
299
+ if (errors.length > 0) {
300
+ const [first = '', ...rest] = errors;
301
+ throw new Error([`${MANIFEST_FILENAME} has problems: ${first}`, ...rest].join('\n'));
302
+ }
282
303
 
283
- emit('generated', tokens);
304
+ emit(
305
+ 'generated',
306
+ tokens,
307
+ manifest.generatedFiles.map((file) => path.resolve(task.directory, file))
308
+ );
284
309
  return { ...task, attempts, tokens, manifest };
285
310
  } catch (error) {
286
311
  lastError = error instanceof Error ? error.message : String(error);
312
+ attemptErrors.push(`Attempt ${attempt}: ${lastError}`);
287
313
  }
288
314
  }
289
315
  if (signal.aborted) {
290
- emit('cancelled', tokens);
291
- return { ...task, attempts, tokens, error: 'run cancelled' };
316
+ const generatedFiles = await discoverGeneratedFiles(task.directory);
317
+ emit('cancelled', tokens, generatedFiles, 'run cancelled');
318
+ return { ...task, attempts, tokens, generatedFiles, error: 'run cancelled' };
292
319
  }
293
- emit('agent_error', tokens);
294
- return { ...task, attempts, tokens, error: lastError };
320
+ const generatedFiles = await discoverGeneratedFiles(task.directory);
321
+ const detailsPath = await writeErrorDetails(task.directory, attemptErrors);
322
+ const error = detailsPath ? `${lastError}\nDetails: ${detailsPath}` : lastError;
323
+ emit('agent_error', tokens, generatedFiles, error);
324
+ return { ...task, attempts, tokens, generatedFiles, error };
295
325
  };
296
326
 
297
327
  return mapWithConcurrency(tasks, concurrency, generateTask);
@@ -1,17 +1,21 @@
1
1
  import fs from 'node:fs/promises';
2
2
 
3
+ import { ensureAgentSdks } from './agentPreflight.js';
3
4
  import { discoverCodeBlocks } from './discoverCodeBlocks.js';
4
5
  import { executeCodeBlocks } from './executeCodeBlocks.js';
5
6
  import { filterSelectedPages } from './filterSelectedPages.js';
6
7
  import { checkTestability, generateTestCode } from './generateTestCode.js';
8
+ import { recordTestRun, testRunReportPath } from './runHistory.js';
7
9
  import { clearRunSave, writeRunSave } from './runSave.js';
8
- import { setupFolders } from './setupFolders.js';
10
+ import { scaffoldTaskDirectories, setupFolders } from './setupFolders.js';
9
11
  import type { CodeTestReport, RunCodeTestsOptions } from './types.js';
10
12
 
11
13
  export const DEFAULT_CONCURRENCY = 5;
12
14
  export const DEFAULT_COMMAND_TIMEOUT_MS = 2 * 60 * 1_000;
13
15
 
14
16
  export { discoverCodeBlocks } from './discoverCodeBlocks.js';
17
+ export { countReport } from './countReport.js';
18
+ export { loadLatestTestRun, loadTestRuns } from './runHistory.js';
15
19
  export { clearRunSave, loadRunSave } from './runSave.js';
16
20
  export type {
17
21
  CodeTestReport,
@@ -26,6 +30,8 @@ export type {
26
30
  TaskResult,
27
31
  TaskStatus,
28
32
  TaskUpdate,
33
+ TestRunSummary,
34
+ TestRunsIndex,
29
35
  } from './types.js';
30
36
 
31
37
  export async function runCodeTests({
@@ -45,14 +51,21 @@ export async function runCodeTests({
45
51
 
46
52
  const { docsRoot, pages: allPages } = await discoverCodeBlocks(docsPath);
47
53
  const pages = filterSelectedPages(allPages, selectedFiles);
54
+ if (pages.length > 0) await ensureAgentSdks(uniqueAgents);
48
55
 
49
- const { runId, runDirectory, reportPath, tasks } = await setupFolders({
56
+ const {
57
+ runId,
58
+ runDirectory,
59
+ reportPath: legacyReportPath,
60
+ tasks,
61
+ } = await setupFolders({
50
62
  pages,
51
63
  agents: uniqueAgents,
52
64
  docsRoot,
53
65
  outputDirectory,
54
66
  runId: savedRunId,
55
67
  });
68
+ const reportPath = testRunReportPath(docsRoot, runId);
56
69
  await writeRunSave(outputDirectory, {
57
70
  version: 1,
58
71
  runId,
@@ -62,7 +75,8 @@ export async function runCodeTests({
62
75
  createdAt: startedAt.toISOString(),
63
76
  });
64
77
 
65
- const checked = await checkTestability({ tasks, signal, onTaskUpdate });
78
+ const checked = await checkTestability({ tasks, runDirectory, signal, onTaskUpdate });
79
+ await scaffoldTaskDirectories(checked.filter((task) => task.testable === true));
66
80
 
67
81
  const generated = await generateTestCode({
68
82
  tasks: checked,
@@ -97,13 +111,14 @@ export async function runCodeTests({
97
111
  outputDirectory: runDirectory,
98
112
  reportPath,
99
113
  agents: uniqueAgents,
114
+ model,
100
115
  selectedFiles: pages.map((page) => page.file),
101
116
  startedAt: startedAt.toISOString(),
102
117
  completedAt: completedAt.toISOString(),
103
118
  durationMs: completedAt.getTime() - startedAt.getTime(),
104
119
  tasks: results,
105
120
  };
106
- await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
121
+ await fs.writeFile(legacyReportPath, `${JSON.stringify(report, null, 2)}\n`);
107
122
  if (!signal.aborted) await clearRunSave(outputDirectory);
108
- return report;
123
+ return recordTestRun(docsRoot, report, legacyReportPath);
109
124
  }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { z } from 'zod';
4
+
5
+ import { manifestSchema, type GeneratedTestManifest } from './types.js';
6
+
7
+ export const MANIFEST_FILENAME = 'mint-test.json';
8
+ export const AGENT_REPLY_FILENAME = 'mint-test-agent.md';
9
+ export const ERROR_FILENAME = 'mint-test-error.txt';
10
+
11
+ export const SAFE_EXECUTABLES = new Set([
12
+ 'bun',
13
+ 'bunx',
14
+ 'bundle',
15
+ 'cargo',
16
+ 'composer',
17
+ 'deno',
18
+ 'dotnet',
19
+ 'go',
20
+ 'gradle',
21
+ 'java',
22
+ 'javac',
23
+ 'mvn',
24
+ 'node',
25
+ 'npm',
26
+ 'npx',
27
+ 'php',
28
+ 'pip',
29
+ 'pip3',
30
+ 'pnpm',
31
+ 'python',
32
+ 'python3',
33
+ 'pytest',
34
+ 'ruby',
35
+ 'rustc',
36
+ 'uv',
37
+ 'vitest',
38
+ 'yarn',
39
+ ]);
40
+
41
+ function isMissingFile(error: unknown): boolean {
42
+ return (
43
+ typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT'
44
+ );
45
+ }
46
+
47
+ /** Turn a schema failure into one readable line per problem, e.g. `testCommands.0.id: expected string`. */
48
+ export function formatManifestIssues(error: z.ZodError): string {
49
+ return error.issues
50
+ .map((issue) => {
51
+ const location = issue.path.map(String).join('.');
52
+ return `${location || MANIFEST_FILENAME}: ${issue.message}`;
53
+ })
54
+ .join('\n');
55
+ }
56
+
57
+ /**
58
+ * Read and schema-check the manifest an agent wrote. Every failure mode has a message a
59
+ * documentation author can act on, because it is what `mint test` shows for the page.
60
+ */
61
+ export async function readManifest(directory: string): Promise<GeneratedTestManifest> {
62
+ const manifestPath = path.join(directory, MANIFEST_FILENAME);
63
+ let raw: string;
64
+ try {
65
+ raw = await fs.readFile(manifestPath, 'utf8');
66
+ } catch (error) {
67
+ if (isMissingFile(error)) {
68
+ throw new Error(`The agent finished without writing ${MANIFEST_FILENAME}`);
69
+ }
70
+ throw error;
71
+ }
72
+
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch (error) {
77
+ const reason = error instanceof Error ? error.message : String(error);
78
+ throw new Error(`${MANIFEST_FILENAME} is not valid JSON: ${reason}`);
79
+ }
80
+
81
+ const result = manifestSchema.safeParse(parsed);
82
+ if (!result.success) {
83
+ const [first = '', ...rest] = formatManifestIssues(result.error).split('\n');
84
+ throw new Error(
85
+ [`${MANIFEST_FILENAME} does not match the expected shape: ${first}`, ...rest].join('\n')
86
+ );
87
+ }
88
+ return result.data;
89
+ }
90
+
91
+ /** Keep the agent's final message next to its output so a failure can be traced later. */
92
+ export async function appendAgentReply(
93
+ directory: string,
94
+ attempt: number,
95
+ reply: string
96
+ ): Promise<void> {
97
+ const body = reply.trim() || '(no summary)';
98
+ await fs
99
+ .appendFile(path.join(directory, AGENT_REPLY_FILENAME), `## Attempt ${attempt}\n\n${body}\n\n`)
100
+ .catch(() => {});
101
+ }
102
+
103
+ /** Persist every attempt's failure reason. Returns the file path, or undefined if it could not be written. */
104
+ export async function writeErrorDetails(
105
+ directory: string,
106
+ errors: string[]
107
+ ): Promise<string | undefined> {
108
+ if (errors.length === 0) return undefined;
109
+ const errorPath = path.join(directory, ERROR_FILENAME);
110
+ try {
111
+ await fs.writeFile(errorPath, `${errors.join('\n\n')}\n`);
112
+ return errorPath;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }