@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
|
@@ -18,39 +18,11 @@ import fs from 'node:fs/promises';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { SYSTEM_PROMPT, buildTaskPrompt } from './buildTaskPrompt.js';
|
|
20
20
|
import { loadClaudeAgentSdk, loadCodexSdk } from './loadAgentSdk.js';
|
|
21
|
+
import { MANIFEST_FILENAME, SAFE_EXECUTABLES, appendAgentReply, readManifest, writeErrorDetails, } from './manifest.js';
|
|
21
22
|
import { mapWithConcurrency } from './mapWithConcurrency.js';
|
|
22
23
|
import { checkFileTestability } from './tasks/checkTestability.js';
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const SAFE_EXECUTABLES = new Set([
|
|
26
|
-
'bun',
|
|
27
|
-
'bunx',
|
|
28
|
-
'bundle',
|
|
29
|
-
'cargo',
|
|
30
|
-
'composer',
|
|
31
|
-
'deno',
|
|
32
|
-
'dotnet',
|
|
33
|
-
'go',
|
|
34
|
-
'gradle',
|
|
35
|
-
'java',
|
|
36
|
-
'javac',
|
|
37
|
-
'mvn',
|
|
38
|
-
'node',
|
|
39
|
-
'npm',
|
|
40
|
-
'npx',
|
|
41
|
-
'php',
|
|
42
|
-
'pip',
|
|
43
|
-
'pip3',
|
|
44
|
-
'pnpm',
|
|
45
|
-
'python',
|
|
46
|
-
'python3',
|
|
47
|
-
'pytest',
|
|
48
|
-
'ruby',
|
|
49
|
-
'rustc',
|
|
50
|
-
'uv',
|
|
51
|
-
'vitest',
|
|
52
|
-
'yarn',
|
|
53
|
-
]);
|
|
24
|
+
const SCAFFOLD_FILENAMES = new Set([MANIFEST_FILENAME, 'package.json']);
|
|
25
|
+
const IGNORED_GENERATED_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
54
26
|
function promptClaude(_a) {
|
|
55
27
|
return __awaiter(this, arguments, void 0, function* ({ prompt, systemPrompt, model, workingDirectory, docsRoot, tools, signal, }) {
|
|
56
28
|
var _b, e_1, _c, _d;
|
|
@@ -188,7 +160,7 @@ function validateManifest(manifest, directory) {
|
|
|
188
160
|
function readValidManifest(directory) {
|
|
189
161
|
return __awaiter(this, void 0, void 0, function* () {
|
|
190
162
|
try {
|
|
191
|
-
const manifest =
|
|
163
|
+
const manifest = yield readManifest(directory);
|
|
192
164
|
const errors = yield validateManifest(manifest, directory);
|
|
193
165
|
return errors.length === 0 ? manifest : null;
|
|
194
166
|
}
|
|
@@ -197,15 +169,47 @@ function readValidManifest(directory) {
|
|
|
197
169
|
}
|
|
198
170
|
});
|
|
199
171
|
}
|
|
172
|
+
function discoverGeneratedFiles(directory) {
|
|
173
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
174
|
+
const realDirectory = yield fs.realpath(directory);
|
|
175
|
+
const files = [];
|
|
176
|
+
function walk(currentDirectory) {
|
|
177
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
178
|
+
const entries = yield fs.readdir(currentDirectory, { withFileTypes: true }).catch(() => []);
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (files.length >= 1000)
|
|
181
|
+
return;
|
|
182
|
+
if (currentDirectory === directory && SCAFFOLD_FILENAMES.has(entry.name))
|
|
183
|
+
continue;
|
|
184
|
+
const candidate = path.join(currentDirectory, entry.name);
|
|
185
|
+
if (entry.isDirectory()) {
|
|
186
|
+
if (IGNORED_GENERATED_DIRECTORIES.has(entry.name))
|
|
187
|
+
continue;
|
|
188
|
+
yield walk(candidate);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!entry.isFile())
|
|
192
|
+
continue;
|
|
193
|
+
const realPath = yield fs.realpath(candidate).catch(() => undefined);
|
|
194
|
+
if (realPath && isPathInside(realDirectory, realPath))
|
|
195
|
+
files.push(path.resolve(candidate));
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
yield walk(directory);
|
|
200
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
200
203
|
export const CHECK_CONCURRENCY = 20;
|
|
201
204
|
export function checkTestability(_a) {
|
|
202
|
-
return __awaiter(this, arguments, void 0, function* ({ tasks, signal, onTaskUpdate, }) {
|
|
205
|
+
return __awaiter(this, arguments, void 0, function* ({ tasks, runDirectory, signal, onTaskUpdate, }) {
|
|
203
206
|
const folderOrdered = [...tasks].sort((left, right) => path.dirname(left.file).localeCompare(path.dirname(right.file)) ||
|
|
204
207
|
left.file.localeCompare(right.file) ||
|
|
205
208
|
left.agent.localeCompare(right.agent));
|
|
206
209
|
return mapWithConcurrency(folderOrdered, CHECK_CONCURRENCY, checkFileTestability, {
|
|
207
210
|
signal,
|
|
208
211
|
onTaskUpdate,
|
|
212
|
+
runDirectory,
|
|
209
213
|
});
|
|
210
214
|
});
|
|
211
215
|
}
|
|
@@ -214,13 +218,16 @@ export function generateTestCode(_a) {
|
|
|
214
218
|
const generateTask = (task) => __awaiter(this, void 0, void 0, function* () {
|
|
215
219
|
if (task.error || !task.testable)
|
|
216
220
|
return task;
|
|
217
|
-
const emit = (phase, tokens) => onTaskUpdate({ id: task.id, agent: task.agent, file: task.file,
|
|
221
|
+
const emit = (phase, tokens, generatedFiles = [], error) => onTaskUpdate(Object.assign({ id: task.id, agent: task.agent, file: task.file, directory: task.directory, generatedFiles,
|
|
222
|
+
phase,
|
|
223
|
+
tokens }, (error ? { error } : {})));
|
|
218
224
|
let attempts = task.attempts;
|
|
219
225
|
let tokens = task.tokens;
|
|
220
226
|
let lastError = 'run cancelled';
|
|
227
|
+
const attemptErrors = [];
|
|
221
228
|
const existing = yield readValidManifest(task.directory);
|
|
222
229
|
if (existing) {
|
|
223
|
-
emit('generated', tokens);
|
|
230
|
+
emit('generated', tokens, existing.generatedFiles.map((file) => path.resolve(task.directory, file)));
|
|
224
231
|
return Object.assign(Object.assign({}, task), { manifest: existing });
|
|
225
232
|
}
|
|
226
233
|
for (let attempt = 1; attempt <= 2 && !signal.aborted; attempt++) {
|
|
@@ -247,23 +254,31 @@ export function generateTestCode(_a) {
|
|
|
247
254
|
signal,
|
|
248
255
|
});
|
|
249
256
|
tokens += reply.tokens;
|
|
250
|
-
|
|
257
|
+
yield appendAgentReply(task.directory, attempt, reply.result);
|
|
258
|
+
const manifest = yield readManifest(task.directory);
|
|
251
259
|
const errors = yield validateManifest(manifest, task.directory);
|
|
252
|
-
if (errors.length > 0)
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
if (errors.length > 0) {
|
|
261
|
+
const [first = '', ...rest] = errors;
|
|
262
|
+
throw new Error([`${MANIFEST_FILENAME} has problems: ${first}`, ...rest].join('\n'));
|
|
263
|
+
}
|
|
264
|
+
emit('generated', tokens, manifest.generatedFiles.map((file) => path.resolve(task.directory, file)));
|
|
255
265
|
return Object.assign(Object.assign({}, task), { attempts, tokens, manifest });
|
|
256
266
|
}
|
|
257
267
|
catch (error) {
|
|
258
268
|
lastError = error instanceof Error ? error.message : String(error);
|
|
269
|
+
attemptErrors.push(`Attempt ${attempt}: ${lastError}`);
|
|
259
270
|
}
|
|
260
271
|
}
|
|
261
272
|
if (signal.aborted) {
|
|
262
|
-
|
|
263
|
-
|
|
273
|
+
const generatedFiles = yield discoverGeneratedFiles(task.directory);
|
|
274
|
+
emit('cancelled', tokens, generatedFiles, 'run cancelled');
|
|
275
|
+
return Object.assign(Object.assign({}, task), { attempts, tokens, generatedFiles, error: 'run cancelled' });
|
|
264
276
|
}
|
|
265
|
-
|
|
266
|
-
|
|
277
|
+
const generatedFiles = yield discoverGeneratedFiles(task.directory);
|
|
278
|
+
const detailsPath = yield writeErrorDetails(task.directory, attemptErrors);
|
|
279
|
+
const error = detailsPath ? `${lastError}\nDetails: ${detailsPath}` : lastError;
|
|
280
|
+
emit('agent_error', tokens, generatedFiles, error);
|
|
281
|
+
return Object.assign(Object.assign({}, task), { attempts, tokens, generatedFiles, error });
|
|
267
282
|
});
|
|
268
283
|
return mapWithConcurrency(tasks, concurrency, generateTask);
|
|
269
284
|
});
|
|
@@ -8,15 +8,19 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
|
+
import { ensureAgentSdks } from './agentPreflight.js';
|
|
11
12
|
import { discoverCodeBlocks } from './discoverCodeBlocks.js';
|
|
12
13
|
import { executeCodeBlocks } from './executeCodeBlocks.js';
|
|
13
14
|
import { filterSelectedPages } from './filterSelectedPages.js';
|
|
14
15
|
import { checkTestability, generateTestCode } from './generateTestCode.js';
|
|
16
|
+
import { recordTestRun, testRunReportPath } from './runHistory.js';
|
|
15
17
|
import { clearRunSave, writeRunSave } from './runSave.js';
|
|
16
|
-
import { setupFolders } from './setupFolders.js';
|
|
18
|
+
import { scaffoldTaskDirectories, setupFolders } from './setupFolders.js';
|
|
17
19
|
export const DEFAULT_CONCURRENCY = 5;
|
|
18
20
|
export const DEFAULT_COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
|
|
19
21
|
export { discoverCodeBlocks } from './discoverCodeBlocks.js';
|
|
22
|
+
export { countReport } from './countReport.js';
|
|
23
|
+
export { loadLatestTestRun, loadTestRuns } from './runHistory.js';
|
|
20
24
|
export { clearRunSave, loadRunSave } from './runSave.js';
|
|
21
25
|
export function runCodeTests(_a) {
|
|
22
26
|
return __awaiter(this, arguments, void 0, function* ({ path: docsPath, agents, model, selectedFiles, outputDirectory, runId: savedRunId, concurrency, commandTimeoutMs, signal, onTaskUpdate, }) {
|
|
@@ -24,13 +28,16 @@ export function runCodeTests(_a) {
|
|
|
24
28
|
const uniqueAgents = [...new Set(agents)];
|
|
25
29
|
const { docsRoot, pages: allPages } = yield discoverCodeBlocks(docsPath);
|
|
26
30
|
const pages = filterSelectedPages(allPages, selectedFiles);
|
|
27
|
-
|
|
31
|
+
if (pages.length > 0)
|
|
32
|
+
yield ensureAgentSdks(uniqueAgents);
|
|
33
|
+
const { runId, runDirectory, reportPath: legacyReportPath, tasks, } = yield setupFolders({
|
|
28
34
|
pages,
|
|
29
35
|
agents: uniqueAgents,
|
|
30
36
|
docsRoot,
|
|
31
37
|
outputDirectory,
|
|
32
38
|
runId: savedRunId,
|
|
33
39
|
});
|
|
40
|
+
const reportPath = testRunReportPath(docsRoot, runId);
|
|
34
41
|
yield writeRunSave(outputDirectory, {
|
|
35
42
|
version: 1,
|
|
36
43
|
runId,
|
|
@@ -39,7 +46,8 @@ export function runCodeTests(_a) {
|
|
|
39
46
|
selectedFiles: pages.map((page) => page.file),
|
|
40
47
|
createdAt: startedAt.toISOString(),
|
|
41
48
|
});
|
|
42
|
-
const checked = yield checkTestability({ tasks, signal, onTaskUpdate });
|
|
49
|
+
const checked = yield checkTestability({ tasks, runDirectory, signal, onTaskUpdate });
|
|
50
|
+
yield scaffoldTaskDirectories(checked.filter((task) => task.testable === true));
|
|
43
51
|
const generated = yield generateTestCode({
|
|
44
52
|
tasks: checked,
|
|
45
53
|
docsRoot,
|
|
@@ -71,15 +79,16 @@ export function runCodeTests(_a) {
|
|
|
71
79
|
outputDirectory: runDirectory,
|
|
72
80
|
reportPath,
|
|
73
81
|
agents: uniqueAgents,
|
|
82
|
+
model,
|
|
74
83
|
selectedFiles: pages.map((page) => page.file),
|
|
75
84
|
startedAt: startedAt.toISOString(),
|
|
76
85
|
completedAt: completedAt.toISOString(),
|
|
77
86
|
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
78
87
|
tasks: results,
|
|
79
88
|
};
|
|
80
|
-
yield fs.writeFile(
|
|
89
|
+
yield fs.writeFile(legacyReportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
81
90
|
if (!signal.aborted)
|
|
82
91
|
yield clearRunSave(outputDirectory);
|
|
83
|
-
return report;
|
|
92
|
+
return recordTestRun(docsRoot, report, legacyReportPath);
|
|
84
93
|
});
|
|
85
94
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { manifestSchema } from './types.js';
|
|
13
|
+
export const MANIFEST_FILENAME = 'mint-test.json';
|
|
14
|
+
export const AGENT_REPLY_FILENAME = 'mint-test-agent.md';
|
|
15
|
+
export const ERROR_FILENAME = 'mint-test-error.txt';
|
|
16
|
+
export 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
|
+
]);
|
|
45
|
+
function isMissingFile(error) {
|
|
46
|
+
return (typeof error === 'object' && error !== null && error.code === 'ENOENT');
|
|
47
|
+
}
|
|
48
|
+
/** Turn a schema failure into one readable line per problem, e.g. `testCommands.0.id: expected string`. */
|
|
49
|
+
export function formatManifestIssues(error) {
|
|
50
|
+
return error.issues
|
|
51
|
+
.map((issue) => {
|
|
52
|
+
const location = issue.path.map(String).join('.');
|
|
53
|
+
return `${location || MANIFEST_FILENAME}: ${issue.message}`;
|
|
54
|
+
})
|
|
55
|
+
.join('\n');
|
|
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 function readManifest(directory) {
|
|
62
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
63
|
+
const manifestPath = path.join(directory, MANIFEST_FILENAME);
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = yield fs.readFile(manifestPath, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (isMissingFile(error)) {
|
|
70
|
+
throw new Error(`The agent finished without writing ${MANIFEST_FILENAME}`);
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
80
|
+
throw new Error(`${MANIFEST_FILENAME} is not valid JSON: ${reason}`);
|
|
81
|
+
}
|
|
82
|
+
const result = manifestSchema.safeParse(parsed);
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
const [first = '', ...rest] = formatManifestIssues(result.error).split('\n');
|
|
85
|
+
throw new Error([`${MANIFEST_FILENAME} does not match the expected shape: ${first}`, ...rest].join('\n'));
|
|
86
|
+
}
|
|
87
|
+
return result.data;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Keep the agent's final message next to its output so a failure can be traced later. */
|
|
91
|
+
export function appendAgentReply(directory, attempt, reply) {
|
|
92
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
93
|
+
const body = reply.trim() || '(no summary)';
|
|
94
|
+
yield fs
|
|
95
|
+
.appendFile(path.join(directory, AGENT_REPLY_FILENAME), `## Attempt ${attempt}\n\n${body}\n\n`)
|
|
96
|
+
.catch(() => { });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Persist every attempt's failure reason. Returns the file path, or undefined if it could not be written. */
|
|
100
|
+
export function writeErrorDetails(directory, errors) {
|
|
101
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
102
|
+
if (errors.length === 0)
|
|
103
|
+
return undefined;
|
|
104
|
+
const errorPath = path.join(directory, ERROR_FILENAME);
|
|
105
|
+
try {
|
|
106
|
+
yield fs.writeFile(errorPath, `${errors.join('\n\n')}\n`);
|
|
107
|
+
return errorPath;
|
|
108
|
+
}
|
|
109
|
+
catch (_a) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { countReport } from './countReport.js';
|
|
16
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
17
|
+
const INDEX_LOCK_FILENAME = 'testRuns.lock';
|
|
18
|
+
const LOCK_RETRY_MS = 20;
|
|
19
|
+
const STALE_LOCK_MS = 30000;
|
|
20
|
+
const LOCK_TIMEOUT_MS = STALE_LOCK_MS + 10000;
|
|
21
|
+
const commandResultSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
phase: z.enum(['setup', 'test']),
|
|
25
|
+
status: z.enum(['passed', 'failed', 'cancelled']),
|
|
26
|
+
executable: z.string(),
|
|
27
|
+
args: z.array(z.string()),
|
|
28
|
+
cwd: z.string(),
|
|
29
|
+
durationMs: z.number().nonnegative(),
|
|
30
|
+
exitCode: z.number().int().nullable(),
|
|
31
|
+
stdout: z.string(),
|
|
32
|
+
stderr: z.string(),
|
|
33
|
+
error: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
const taskResultSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
agent: z.enum(['claude', 'codex']),
|
|
40
|
+
file: z.string(),
|
|
41
|
+
directory: z.string(),
|
|
42
|
+
generatedFiles: z.array(z.string()),
|
|
43
|
+
testable: z.boolean(),
|
|
44
|
+
attempts: z.number().int().nonnegative(),
|
|
45
|
+
tokens: z.number().nonnegative(),
|
|
46
|
+
status: z.enum(['passed', 'failed', 'agent_error', 'cancelled']),
|
|
47
|
+
error: z.string().optional(),
|
|
48
|
+
commands: z.array(commandResultSchema),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
51
|
+
const codeTestReportSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
version: z.literal(1),
|
|
54
|
+
runId: z.string().regex(RUN_ID_PATTERN),
|
|
55
|
+
status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
|
|
56
|
+
docsRoot: z.string(),
|
|
57
|
+
outputDirectory: z.string(),
|
|
58
|
+
reportPath: z.string(),
|
|
59
|
+
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
60
|
+
model: z.string().min(1),
|
|
61
|
+
selectedFiles: z.array(z.string()),
|
|
62
|
+
startedAt: z.string(),
|
|
63
|
+
completedAt: z.string(),
|
|
64
|
+
durationMs: z.number().nonnegative(),
|
|
65
|
+
tasks: z.array(taskResultSchema),
|
|
66
|
+
historyError: z.string().optional(),
|
|
67
|
+
})
|
|
68
|
+
.strict();
|
|
69
|
+
const countsSchema = z
|
|
70
|
+
.object({
|
|
71
|
+
passed: z.number().int().nonnegative(),
|
|
72
|
+
failed: z.number().int().nonnegative(),
|
|
73
|
+
cancelled: z.number().int().nonnegative(),
|
|
74
|
+
agentErrors: z.number().int().nonnegative(),
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
const testRunSummarySchema = z
|
|
78
|
+
.object({
|
|
79
|
+
runId: z.string().regex(RUN_ID_PATTERN),
|
|
80
|
+
status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
|
|
81
|
+
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
82
|
+
model: z.string().min(1),
|
|
83
|
+
selectedFiles: z.array(z.string()),
|
|
84
|
+
startedAt: z.string(),
|
|
85
|
+
completedAt: z.string(),
|
|
86
|
+
durationMs: z.number().nonnegative(),
|
|
87
|
+
reportPath: z.string(),
|
|
88
|
+
outputDirectory: z.string(),
|
|
89
|
+
counts: countsSchema,
|
|
90
|
+
})
|
|
91
|
+
.strict();
|
|
92
|
+
const testRunsIndexSchema = z
|
|
93
|
+
.object({ version: z.literal(1), runs: z.array(testRunSummarySchema) })
|
|
94
|
+
.strict();
|
|
95
|
+
function historyDirectory(docsRoot) {
|
|
96
|
+
return path.join(docsRoot, '.mintlify', 'test');
|
|
97
|
+
}
|
|
98
|
+
function runsDirectory(docsRoot) {
|
|
99
|
+
return path.join(historyDirectory(docsRoot), 'runs');
|
|
100
|
+
}
|
|
101
|
+
export function testRunReportPath(docsRoot, runId) {
|
|
102
|
+
if (!RUN_ID_PATTERN.test(runId))
|
|
103
|
+
throw new Error(`Invalid mint test run ID: ${runId}`);
|
|
104
|
+
return path.join(runsDirectory(docsRoot), `${runId}.json`);
|
|
105
|
+
}
|
|
106
|
+
export function testRunsIndexPath(docsRoot) {
|
|
107
|
+
return path.join(historyDirectory(docsRoot), 'testRuns.json');
|
|
108
|
+
}
|
|
109
|
+
function summarizeReport(report) {
|
|
110
|
+
return {
|
|
111
|
+
runId: report.runId,
|
|
112
|
+
status: report.status,
|
|
113
|
+
agents: report.agents,
|
|
114
|
+
model: report.model,
|
|
115
|
+
selectedFiles: report.selectedFiles,
|
|
116
|
+
startedAt: report.startedAt,
|
|
117
|
+
completedAt: report.completedAt,
|
|
118
|
+
durationMs: report.durationMs,
|
|
119
|
+
reportPath: report.reportPath,
|
|
120
|
+
outputDirectory: report.outputDirectory,
|
|
121
|
+
counts: countReport(report.tasks),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function errorCode(error) {
|
|
125
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
126
|
+
? String(error.code)
|
|
127
|
+
: undefined;
|
|
128
|
+
}
|
|
129
|
+
function writeJsonAtomically(filePath, value) {
|
|
130
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
131
|
+
const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
132
|
+
try {
|
|
133
|
+
yield fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
134
|
+
yield fs.rename(temporaryPath, filePath);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
yield fs.unlink(temporaryPath).catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function acquireIndexLock(docsRoot) {
|
|
142
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
143
|
+
const lockPath = path.join(historyDirectory(docsRoot), INDEX_LOCK_FILENAME);
|
|
144
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
145
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
146
|
+
while (Date.now() < deadline) {
|
|
147
|
+
try {
|
|
148
|
+
const lock = yield fs.open(lockPath, 'wx');
|
|
149
|
+
try {
|
|
150
|
+
yield lock.writeFile(token);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
yield lock.close();
|
|
154
|
+
}
|
|
155
|
+
return () => __awaiter(this, void 0, void 0, function* () {
|
|
156
|
+
const owner = yield fs.readFile(lockPath, 'utf8').catch(() => undefined);
|
|
157
|
+
if (owner === token)
|
|
158
|
+
yield fs.unlink(lockPath).catch(() => undefined);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (errorCode(error) !== 'EEXIST')
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
const stats = yield fs.stat(lockPath).catch(() => undefined);
|
|
166
|
+
if (stats && Date.now() - stats.mtimeMs > STALE_LOCK_MS) {
|
|
167
|
+
yield fs.unlink(lockPath).catch(() => undefined);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
yield delay(LOCK_RETRY_MS);
|
|
171
|
+
}
|
|
172
|
+
throw new Error('Timed out updating mint test run history');
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function readReport(filePath) {
|
|
176
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
177
|
+
try {
|
|
178
|
+
return codeTestReportSchema.parse(JSON.parse(yield fs.readFile(filePath, 'utf8')));
|
|
179
|
+
}
|
|
180
|
+
catch (_a) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function rebuildIndex(docsRoot) {
|
|
186
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
187
|
+
const entries = yield fs
|
|
188
|
+
.readdir(runsDirectory(docsRoot), { withFileTypes: true })
|
|
189
|
+
.catch(() => []);
|
|
190
|
+
const reports = yield Promise.all(entries
|
|
191
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
192
|
+
.map((entry) => readReport(path.join(runsDirectory(docsRoot), entry.name))));
|
|
193
|
+
return {
|
|
194
|
+
version: 1,
|
|
195
|
+
runs: reports
|
|
196
|
+
.filter((report) => report !== null)
|
|
197
|
+
.map(summarizeReport)
|
|
198
|
+
.sort((left, right) => right.completedAt.localeCompare(left.completedAt)),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export function loadTestRuns(docsRoot) {
|
|
203
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
204
|
+
try {
|
|
205
|
+
return testRunsIndexSchema.parse(JSON.parse(yield fs.readFile(testRunsIndexPath(docsRoot), 'utf8')));
|
|
206
|
+
}
|
|
207
|
+
catch (_a) {
|
|
208
|
+
return rebuildIndex(docsRoot);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export function loadLatestTestRun(docsRoot) {
|
|
213
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
214
|
+
const index = yield loadTestRuns(docsRoot);
|
|
215
|
+
for (const run of index.runs) {
|
|
216
|
+
const report = yield readReport(testRunReportPath(docsRoot, run.runId));
|
|
217
|
+
if (report)
|
|
218
|
+
return report;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export function writeTestRunHistory(docsRoot, report) {
|
|
224
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
225
|
+
yield fs.mkdir(runsDirectory(docsRoot), { recursive: true });
|
|
226
|
+
yield writeJsonAtomically(report.reportPath, report);
|
|
227
|
+
const releaseLock = yield acquireIndexLock(docsRoot);
|
|
228
|
+
try {
|
|
229
|
+
yield writeJsonAtomically(testRunsIndexPath(docsRoot), yield rebuildIndex(docsRoot));
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
yield releaseLock();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Saves a finished run to history without letting a history failure fail the run.
|
|
238
|
+
* On failure the returned report points at `fallbackReportPath` (the report that was
|
|
239
|
+
* already written) and carries the problem in `historyError`.
|
|
240
|
+
*/
|
|
241
|
+
export function recordTestRun(docsRoot, report, fallbackReportPath) {
|
|
242
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
243
|
+
try {
|
|
244
|
+
yield writeTestRunHistory(docsRoot, report);
|
|
245
|
+
return report;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
249
|
+
return Object.assign(Object.assign({}, report), { reportPath: fallbackReportPath, historyError: `run history could not be saved: ${message}` });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
@@ -14,14 +14,30 @@ const PACKAGE_JSON = `${JSON.stringify({
|
|
|
14
14
|
name: 'mint-test-task',
|
|
15
15
|
private: true,
|
|
16
16
|
type: 'module',
|
|
17
|
-
|
|
17
|
+
// No positional path: Node 21+ treats --test arguments as globs, so a bare
|
|
18
|
+
// directory such as `tests/` fails with MODULE_NOT_FOUND. The default patterns
|
|
19
|
+
// pick up tests/*.test.js on every supported Node version.
|
|
20
|
+
scripts: { test: 'node --test' },
|
|
18
21
|
}, null, 2)}\n`;
|
|
22
|
+
function isExistingFile(error) {
|
|
23
|
+
return (typeof error === 'object' && error !== null && error.code === 'EEXIST');
|
|
24
|
+
}
|
|
19
25
|
function scaffoldTaskDirectory(directory) {
|
|
20
26
|
return __awaiter(this, void 0, void 0, function* () {
|
|
21
27
|
yield fs.mkdir(path.join(directory, 'tests'), { recursive: true });
|
|
22
|
-
|
|
23
|
-
.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' })
|
|
24
|
-
|
|
28
|
+
try {
|
|
29
|
+
yield fs.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!isExistingFile(error))
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function scaffoldTaskDirectories(tasks) {
|
|
38
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
39
|
+
for (const task of tasks)
|
|
40
|
+
yield scaffoldTaskDirectory(task.directory);
|
|
25
41
|
});
|
|
26
42
|
}
|
|
27
43
|
export function setupFolders(_a) {
|
|
@@ -52,8 +68,6 @@ export function setupFolders(_a) {
|
|
|
52
68
|
});
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
|
-
for (const task of tasks)
|
|
56
|
-
yield scaffoldTaskDirectory(task.directory);
|
|
57
71
|
return { runId, runDirectory, reportPath, tasks };
|
|
58
72
|
});
|
|
59
73
|
}
|
|
@@ -47,9 +47,8 @@ function parseJsonReply(reply) {
|
|
|
47
47
|
return JSON.parse(raw.slice(start, end + 1));
|
|
48
48
|
}
|
|
49
49
|
function runTestabilityCheck(_a) {
|
|
50
|
-
return __awaiter(this, arguments, void 0, function* ({ file, content, agent,
|
|
50
|
+
return __awaiter(this, arguments, void 0, function* ({ file, content, agent, workingDirectory, signal, }) {
|
|
51
51
|
const prompt = buildCheckPrompt(file, content);
|
|
52
|
-
const workingDirectory = path.dirname(outputPath);
|
|
53
52
|
let tokens = 0;
|
|
54
53
|
let raw;
|
|
55
54
|
if (agent === 'claude') {
|
|
@@ -73,15 +72,16 @@ function runTestabilityCheck(_a) {
|
|
|
73
72
|
const errors = validateCheck(check);
|
|
74
73
|
if (errors.length > 0)
|
|
75
74
|
throw new Error(errors.join('\n'));
|
|
76
|
-
yield fs.writeFile(outputPath, `${JSON.stringify(check, null, 2)}\n`);
|
|
77
75
|
return { tokens, check };
|
|
78
76
|
});
|
|
79
77
|
}
|
|
80
|
-
export const checkFileTestability = (task_1, _a) => __awaiter(void 0, [task_1, _a], void 0, function* (task, { onTaskUpdate, signal }) {
|
|
78
|
+
export const checkFileTestability = (task_1, _a) => __awaiter(void 0, [task_1, _a], void 0, function* (task, { onTaskUpdate, signal, runDirectory, }) {
|
|
81
79
|
const emit = (phase, tokens) => onTaskUpdate({
|
|
82
80
|
id: task.id,
|
|
83
81
|
agent: task.agent,
|
|
84
82
|
file: task.file,
|
|
83
|
+
directory: task.directory,
|
|
84
|
+
generatedFiles: [],
|
|
85
85
|
phase,
|
|
86
86
|
tokens,
|
|
87
87
|
});
|
|
@@ -99,7 +99,7 @@ export const checkFileTestability = (task_1, _a) => __awaiter(void 0, [task_1, _
|
|
|
99
99
|
file: task.file,
|
|
100
100
|
content,
|
|
101
101
|
agent: task.agent,
|
|
102
|
-
|
|
102
|
+
workingDirectory: runDirectory,
|
|
103
103
|
signal,
|
|
104
104
|
});
|
|
105
105
|
tokens += reply.tokens;
|
|
@@ -122,6 +122,8 @@ export const checkFileTestability = (task_1, _a) => __awaiter(void 0, [task_1, _
|
|
|
122
122
|
emit('not_testable', tokens);
|
|
123
123
|
return Object.assign(Object.assign({}, task), { attempts, tokens, testable: false });
|
|
124
124
|
}
|
|
125
|
+
yield fs.mkdir(task.directory, { recursive: true });
|
|
126
|
+
yield fs.writeFile(path.join(task.directory, CHECK_FILENAME), `${JSON.stringify(check, null, 2)}\n`);
|
|
125
127
|
emit('checked', tokens);
|
|
126
128
|
return Object.assign(Object.assign({}, task), { attempts, tokens, testable: true });
|
|
127
129
|
});
|