@mintlify/cli 4.0.1466 → 4.0.1467
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/__test__/mintTestFilePreview.test.ts +35 -0
- package/__test__/mintTestOutput.test.ts +194 -0
- package/__test__/runHistory.test.ts +164 -0
- package/bin/agent-harness/agentPreflight.js +24 -0
- package/bin/agent-harness/buildTaskPrompt.js +17 -7
- package/bin/agent-harness/countReport.js +10 -0
- package/bin/agent-harness/executeCodeBlocks.js +53 -10
- package/bin/agent-harness/generateTestCode.js +58 -43
- package/bin/agent-harness/index.js +14 -5
- package/bin/agent-harness/manifest.js +113 -0
- package/bin/agent-harness/runHistory.js +252 -0
- package/bin/agent-harness/setupFolders.js +20 -6
- package/bin/agent-harness/tasks/checkTestability.js +7 -5
- package/bin/agent-harness/types.js +33 -2
- package/bin/constants.js +3 -3
- package/bin/mintTest.js +31 -9
- package/bin/mintTestFilePreview.js +39 -0
- package/bin/mintTestText.js +31 -0
- package/bin/mintTestUi.js +295 -105
- package/bin/status.js +26 -0
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +15 -9
- package/src/agent-harness/agentPreflight.ts +13 -0
- package/src/agent-harness/buildTaskPrompt.ts +18 -7
- package/src/agent-harness/countReport.ts +17 -0
- package/src/agent-harness/executeCodeBlocks.ts +67 -1
- package/src/agent-harness/generateTestCode.ts +81 -51
- package/src/agent-harness/index.ts +20 -5
- package/src/agent-harness/manifest.ts +116 -0
- package/src/agent-harness/runHistory.ts +250 -0
- package/src/agent-harness/setupFolders.ts +19 -5
- package/src/agent-harness/tasks/checkTestability.ts +15 -6
- package/src/agent-harness/types.ts +81 -5
- package/src/constants.ts +6 -2
- package/src/mintTest.tsx +34 -8
- package/src/mintTestFilePreview.ts +32 -0
- package/src/mintTestText.ts +35 -0
- package/src/mintTestUi.tsx +586 -224
- 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.
|
|
3
|
+
"version": "4.0.1467",
|
|
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": "
|
|
108
|
+
"gitHead": "467448ed17b5c39cb838780e1baf2c9706fa48e4"
|
|
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
|
|
52
|
-
| `CodeTestReport` | The complete persisted `report.json` shape. Task, block, command, environment, summary, and status fields are nested here.
|
|
53
|
-
|
|
54
|
-
`GeneratedTestManifest` is
|
|
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
|
|
6
|
-
the code as
|
|
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"
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
${
|
|
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
|
|
16
|
-
const
|
|
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 =
|
|
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 = (
|
|
242
|
-
|
|
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(
|
|
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
|
-
|
|
278
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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 {
|
|
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(
|
|
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
|
+
}
|