@mintlify/cli 4.0.1654 → 4.0.1656
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/bin/cli.js +1 -7
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +9 -13
- package/src/cli.tsx +1 -12
- package/__test__/mintTestFilePreview.test.ts +0 -35
- package/__test__/mintTestOutput.test.ts +0 -256
- package/__test__/runHistory.test.ts +0 -240
- package/bin/agent-harness/agentPreflight.js +0 -24
- package/bin/agent-harness/buildTaskPrompt.js +0 -86
- package/bin/agent-harness/countReport.js +0 -10
- package/bin/agent-harness/discoverCodeBlocks.js +0 -101
- package/bin/agent-harness/executeCodeBlocks.js +0 -263
- package/bin/agent-harness/filterSelectedPages.js +0 -10
- package/bin/agent-harness/generateTestCode.js +0 -337
- package/bin/agent-harness/index.js +0 -95
- package/bin/agent-harness/loadAgentSdk.js +0 -46
- package/bin/agent-harness/manifest.js +0 -113
- package/bin/agent-harness/mapWithConcurrency.js +0 -26
- package/bin/agent-harness/runHistory.js +0 -256
- package/bin/agent-harness/runSave.js +0 -47
- package/bin/agent-harness/setupFolders.js +0 -73
- package/bin/agent-harness/tasks/checkTestability.js +0 -228
- package/bin/agent-harness/types.js +0 -66
- package/bin/mintTest.js +0 -122
- package/bin/mintTestFilePreview.js +0 -39
- package/bin/mintTestNav.js +0 -35
- package/bin/mintTestTelemetry.js +0 -63
- package/bin/mintTestText.js +0 -31
- package/bin/mintTestTree.js +0 -94
- package/bin/mintTestUi.js +0 -818
- package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +0 -64
- package/src/agent-harness/agentPreflight.ts +0 -13
- package/src/agent-harness/buildTaskPrompt.ts +0 -93
- package/src/agent-harness/countReport.ts +0 -17
- package/src/agent-harness/discoverCodeBlocks.ts +0 -90
- package/src/agent-harness/executeCodeBlocks.ts +0 -351
- package/src/agent-harness/filterSelectedPages.ts +0 -14
- package/src/agent-harness/generateTestCode.ts +0 -396
- package/src/agent-harness/index.ts +0 -127
- package/src/agent-harness/loadAgentSdk.ts +0 -35
- package/src/agent-harness/manifest.ts +0 -116
- package/src/agent-harness/mapWithConcurrency.ts +0 -21
- package/src/agent-harness/runHistory.ts +0 -255
- package/src/agent-harness/runSave.ts +0 -38
- package/src/agent-harness/setupFolders.ts +0 -87
- package/src/agent-harness/tasks/checkTestability.ts +0 -256
- package/src/agent-harness/types.ts +0 -239
- package/src/mintTest.tsx +0 -138
- package/src/mintTestFilePreview.ts +0 -32
- package/src/mintTestNav.ts +0 -31
- package/src/mintTestTelemetry.ts +0 -101
- package/src/mintTestText.ts +0 -35
- package/src/mintTestTree.ts +0 -134
- package/src/mintTestUi.tsx +0 -1378
|
@@ -1,396 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
import { SYSTEM_PROMPT, buildTaskPrompt } from './buildTaskPrompt.js';
|
|
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';
|
|
13
|
-
import { mapWithConcurrency } from './mapWithConcurrency.js';
|
|
14
|
-
import { checkFileTestability } from './tasks/checkTestability.js';
|
|
15
|
-
import type {
|
|
16
|
-
GeneratedTask,
|
|
17
|
-
GeneratedTestManifest,
|
|
18
|
-
TaskUpdate,
|
|
19
|
-
TestGenerationResult,
|
|
20
|
-
} from './types.js';
|
|
21
|
-
|
|
22
|
-
const SCAFFOLD_FILENAMES = new Set([MANIFEST_FILENAME, 'package.json']);
|
|
23
|
-
const IGNORED_GENERATED_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
24
|
-
|
|
25
|
-
async function promptClaude({
|
|
26
|
-
prompt,
|
|
27
|
-
systemPrompt,
|
|
28
|
-
model,
|
|
29
|
-
workingDirectory,
|
|
30
|
-
docsRoot,
|
|
31
|
-
tools,
|
|
32
|
-
signal,
|
|
33
|
-
}: {
|
|
34
|
-
prompt: string;
|
|
35
|
-
systemPrompt: string;
|
|
36
|
-
model: string;
|
|
37
|
-
workingDirectory: string;
|
|
38
|
-
docsRoot: string;
|
|
39
|
-
tools: string[];
|
|
40
|
-
signal: AbortSignal;
|
|
41
|
-
}): Promise<{ tokens: number; result: string }> {
|
|
42
|
-
const { query } = await loadClaudeAgentSdk();
|
|
43
|
-
const abortController = new AbortController();
|
|
44
|
-
const abort = () => abortController.abort(signal.reason);
|
|
45
|
-
if (signal.aborted) abort();
|
|
46
|
-
else signal.addEventListener('abort', abort, { once: true });
|
|
47
|
-
|
|
48
|
-
const session = query({
|
|
49
|
-
prompt,
|
|
50
|
-
options: {
|
|
51
|
-
abortController,
|
|
52
|
-
additionalDirectories: [docsRoot],
|
|
53
|
-
allowedTools: tools,
|
|
54
|
-
cwd: workingDirectory,
|
|
55
|
-
model,
|
|
56
|
-
permissionMode: 'dontAsk',
|
|
57
|
-
persistSession: false,
|
|
58
|
-
settingSources: [],
|
|
59
|
-
strictMcpConfig: true,
|
|
60
|
-
systemPrompt,
|
|
61
|
-
tools,
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
for await (const message of session) {
|
|
67
|
-
if (message.type !== 'result') continue;
|
|
68
|
-
if (message.subtype !== 'success' || message.is_error) {
|
|
69
|
-
const details = message.subtype === 'success' ? message.result : message.errors.join('; ');
|
|
70
|
-
throw new Error(details || `Claude stopped with ${message.subtype}`);
|
|
71
|
-
}
|
|
72
|
-
const usage = message.usage;
|
|
73
|
-
return {
|
|
74
|
-
tokens:
|
|
75
|
-
usage.input_tokens +
|
|
76
|
-
usage.output_tokens +
|
|
77
|
-
usage.cache_creation_input_tokens +
|
|
78
|
-
usage.cache_read_input_tokens,
|
|
79
|
-
result: message.result,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
throw new Error('Claude finished without returning a result');
|
|
83
|
-
} finally {
|
|
84
|
-
signal.removeEventListener('abort', abort);
|
|
85
|
-
session.close();
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const AGENT_TOOLS = ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash'];
|
|
90
|
-
|
|
91
|
-
function isPathInside(root: string, candidate: string): boolean {
|
|
92
|
-
const relative = path.relative(root, candidate);
|
|
93
|
-
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function validateManifest(
|
|
97
|
-
manifest: GeneratedTestManifest,
|
|
98
|
-
directory: string
|
|
99
|
-
): Promise<string[]> {
|
|
100
|
-
const errors: string[] = [];
|
|
101
|
-
const realDirectory = await fs.realpath(directory);
|
|
102
|
-
|
|
103
|
-
for (const file of manifest.generatedFiles) {
|
|
104
|
-
if (path.isAbsolute(file)) {
|
|
105
|
-
errors.push(`Generated file path must be relative: ${file}`);
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
const resolved = path.resolve(directory, file);
|
|
109
|
-
if (!isPathInside(directory, resolved)) {
|
|
110
|
-
errors.push(`Generated file leaves its task: ${file}`);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const realPath = await fs.realpath(resolved).catch(() => undefined);
|
|
114
|
-
if (!realPath || !isPathInside(realDirectory, realPath)) {
|
|
115
|
-
errors.push(`Generated file leaves its task through a link: ${file}`);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const stats = await fs.stat(realPath).catch(() => undefined);
|
|
119
|
-
if (!stats?.isFile()) errors.push(`Generated file does not exist: ${file}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const commandIds = new Set<string>();
|
|
123
|
-
|
|
124
|
-
for (const command of [...manifest.setupCommands, ...manifest.testCommands]) {
|
|
125
|
-
if (commandIds.has(command.id)) errors.push(`Duplicate command ID: ${command.id}`);
|
|
126
|
-
commandIds.add(command.id);
|
|
127
|
-
if (path.isAbsolute(command.executable) || command.executable.split(/[\\/]/).includes('..')) {
|
|
128
|
-
errors.push(`Unsafe command executable: ${command.executable}`);
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
if (!SAFE_EXECUTABLES.has(command.executable) && !command.executable.startsWith('./')) {
|
|
132
|
-
errors.push(`Unsupported command executable: ${command.executable}`);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (command.executable.startsWith('./')) {
|
|
136
|
-
const executablePath = await fs
|
|
137
|
-
.realpath(path.resolve(directory, command.executable))
|
|
138
|
-
.catch(() => undefined);
|
|
139
|
-
const stats = executablePath
|
|
140
|
-
? await fs.stat(executablePath).catch(() => undefined)
|
|
141
|
-
: undefined;
|
|
142
|
-
if (!executablePath || !isPathInside(realDirectory, executablePath) || !stats?.isFile()) {
|
|
143
|
-
errors.push(`Command executable leaves its task or is not a file: ${command.executable}`);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
const cwd = command.cwd ?? '.';
|
|
147
|
-
if (path.isAbsolute(cwd)) {
|
|
148
|
-
errors.push(`Command cwd must be relative: ${cwd}`);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
const resolvedCwd = path.resolve(directory, cwd);
|
|
152
|
-
const realCwd = await fs.realpath(resolvedCwd).catch(() => undefined);
|
|
153
|
-
const stats = realCwd ? await fs.stat(realCwd).catch(() => undefined) : undefined;
|
|
154
|
-
if (
|
|
155
|
-
!isPathInside(directory, resolvedCwd) ||
|
|
156
|
-
!realCwd ||
|
|
157
|
-
!isPathInside(realDirectory, realCwd) ||
|
|
158
|
-
!stats?.isDirectory()
|
|
159
|
-
) {
|
|
160
|
-
errors.push(`Command cwd is missing or leaves its task: ${cwd}`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return errors;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function readValidManifest(directory: string): Promise<GeneratedTestManifest | null> {
|
|
168
|
-
try {
|
|
169
|
-
const manifest = await readManifest(directory);
|
|
170
|
-
const errors = await validateManifest(manifest, directory);
|
|
171
|
-
return errors.length === 0 ? manifest : null;
|
|
172
|
-
} catch {
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function discoverGeneratedFiles(directory: string): Promise<string[]> {
|
|
178
|
-
const realDirectory = await fs.realpath(directory);
|
|
179
|
-
const files: string[] = [];
|
|
180
|
-
|
|
181
|
-
async function walk(currentDirectory: string): Promise<void> {
|
|
182
|
-
const entries = await fs.readdir(currentDirectory, { withFileTypes: true }).catch(() => []);
|
|
183
|
-
for (const entry of entries) {
|
|
184
|
-
if (files.length >= 1_000) return;
|
|
185
|
-
if (currentDirectory === directory && SCAFFOLD_FILENAMES.has(entry.name)) continue;
|
|
186
|
-
const candidate = path.join(currentDirectory, entry.name);
|
|
187
|
-
if (entry.isDirectory()) {
|
|
188
|
-
if (IGNORED_GENERATED_DIRECTORIES.has(entry.name)) continue;
|
|
189
|
-
await walk(candidate);
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (!entry.isFile()) continue;
|
|
193
|
-
const realPath = await fs.realpath(candidate).catch(() => undefined);
|
|
194
|
-
if (realPath && isPathInside(realDirectory, realPath)) files.push(path.resolve(candidate));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
await walk(directory);
|
|
199
|
-
return files.sort((left, right) => left.localeCompare(right));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export const CHECK_CONCURRENCY = 20;
|
|
203
|
-
|
|
204
|
-
export async function checkTestability({
|
|
205
|
-
tasks,
|
|
206
|
-
runDirectory,
|
|
207
|
-
signal,
|
|
208
|
-
onTaskUpdate,
|
|
209
|
-
}: {
|
|
210
|
-
tasks: GeneratedTask[];
|
|
211
|
-
runDirectory: string;
|
|
212
|
-
signal: AbortSignal;
|
|
213
|
-
onTaskUpdate: (update: TaskUpdate) => void;
|
|
214
|
-
}): Promise<GeneratedTask[]> {
|
|
215
|
-
const folderOrdered = [...tasks].sort(
|
|
216
|
-
(left, right) =>
|
|
217
|
-
path.dirname(left.file).localeCompare(path.dirname(right.file)) ||
|
|
218
|
-
left.file.localeCompare(right.file) ||
|
|
219
|
-
left.agent.localeCompare(right.agent)
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
return mapWithConcurrency(folderOrdered, CHECK_CONCURRENCY, checkFileTestability, {
|
|
223
|
-
signal,
|
|
224
|
-
onTaskUpdate,
|
|
225
|
-
runDirectory,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export async function generateTestCode({
|
|
230
|
-
tasks,
|
|
231
|
-
docsRoot,
|
|
232
|
-
model,
|
|
233
|
-
concurrency,
|
|
234
|
-
signal,
|
|
235
|
-
onTaskUpdate,
|
|
236
|
-
onGenerationResult,
|
|
237
|
-
}: {
|
|
238
|
-
tasks: GeneratedTask[];
|
|
239
|
-
docsRoot: string;
|
|
240
|
-
model: string;
|
|
241
|
-
concurrency: number;
|
|
242
|
-
signal: AbortSignal;
|
|
243
|
-
onTaskUpdate: (update: TaskUpdate) => void;
|
|
244
|
-
onGenerationResult?: (result: TestGenerationResult) => void;
|
|
245
|
-
}): Promise<GeneratedTask[]> {
|
|
246
|
-
const generateTask = async (task: GeneratedTask): Promise<GeneratedTask> => {
|
|
247
|
-
if (task.error || !task.testable) return task;
|
|
248
|
-
const emit = (
|
|
249
|
-
phase: TaskUpdate['phase'],
|
|
250
|
-
tokens: number,
|
|
251
|
-
generatedFiles: string[] = [],
|
|
252
|
-
error?: string
|
|
253
|
-
) =>
|
|
254
|
-
onTaskUpdate({
|
|
255
|
-
id: task.id,
|
|
256
|
-
agent: task.agent,
|
|
257
|
-
file: task.file,
|
|
258
|
-
directory: task.directory,
|
|
259
|
-
generatedFiles,
|
|
260
|
-
phase,
|
|
261
|
-
tokens,
|
|
262
|
-
...(error ? { error } : {}),
|
|
263
|
-
});
|
|
264
|
-
let attempts = task.attempts;
|
|
265
|
-
let tokens = task.tokens;
|
|
266
|
-
let lastError = 'run cancelled';
|
|
267
|
-
const attemptErrors: string[] = [];
|
|
268
|
-
|
|
269
|
-
const existing = await readValidManifest(task.directory);
|
|
270
|
-
if (existing) {
|
|
271
|
-
emit(
|
|
272
|
-
'generated',
|
|
273
|
-
tokens,
|
|
274
|
-
existing.generatedFiles.map((file) => path.resolve(task.directory, file))
|
|
275
|
-
);
|
|
276
|
-
return { ...task, manifest: existing };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const startedAt = Date.now();
|
|
280
|
-
const startingTokens = tokens;
|
|
281
|
-
let generationAttempts = 0;
|
|
282
|
-
const emitGenerationResult = (result: TestGenerationResult) => {
|
|
283
|
-
try {
|
|
284
|
-
onGenerationResult?.(result);
|
|
285
|
-
} catch {}
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
for (let attempt = 1; attempt <= 2 && !signal.aborted; attempt++) {
|
|
289
|
-
attempts += 1;
|
|
290
|
-
generationAttempts += 1;
|
|
291
|
-
if (attempt === 1) emit('generating', tokens);
|
|
292
|
-
try {
|
|
293
|
-
const prompt = buildTaskPrompt(docsRoot, task.file, task.directory);
|
|
294
|
-
const reply =
|
|
295
|
-
task.agent === 'claude'
|
|
296
|
-
? await promptClaude({
|
|
297
|
-
prompt,
|
|
298
|
-
systemPrompt: SYSTEM_PROMPT,
|
|
299
|
-
model,
|
|
300
|
-
workingDirectory: task.directory,
|
|
301
|
-
docsRoot,
|
|
302
|
-
tools: AGENT_TOOLS,
|
|
303
|
-
signal,
|
|
304
|
-
})
|
|
305
|
-
: await promptCodex({
|
|
306
|
-
prompt,
|
|
307
|
-
systemPrompt: SYSTEM_PROMPT,
|
|
308
|
-
workingDirectory: task.directory,
|
|
309
|
-
docsRoot,
|
|
310
|
-
signal,
|
|
311
|
-
});
|
|
312
|
-
tokens += reply.tokens;
|
|
313
|
-
await appendAgentReply(task.directory, attempt, reply.result);
|
|
314
|
-
const manifest = await readManifest(task.directory);
|
|
315
|
-
const errors = await validateManifest(manifest, task.directory);
|
|
316
|
-
if (errors.length > 0) {
|
|
317
|
-
const [first = '', ...rest] = errors;
|
|
318
|
-
throw new Error([`${MANIFEST_FILENAME} has problems: ${first}`, ...rest].join('\n'));
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const generatedFiles = manifest.generatedFiles.map((file) =>
|
|
322
|
-
path.resolve(task.directory, file)
|
|
323
|
-
);
|
|
324
|
-
emit('generated', tokens, generatedFiles);
|
|
325
|
-
emitGenerationResult({
|
|
326
|
-
status: 'succeeded',
|
|
327
|
-
agent: task.agent,
|
|
328
|
-
model,
|
|
329
|
-
attemptCount: generationAttempts,
|
|
330
|
-
tokenCount: tokens - startingTokens,
|
|
331
|
-
durationMs: Date.now() - startedAt,
|
|
332
|
-
generatedFileCount: generatedFiles.length,
|
|
333
|
-
});
|
|
334
|
-
return { ...task, attempts, tokens, manifest };
|
|
335
|
-
} catch (error) {
|
|
336
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
337
|
-
attemptErrors.push(`Attempt ${attempt}: ${lastError}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
if (signal.aborted) {
|
|
341
|
-
const generatedFiles = await discoverGeneratedFiles(task.directory);
|
|
342
|
-
emit('cancelled', tokens, generatedFiles, 'run cancelled');
|
|
343
|
-
return { ...task, attempts, tokens, generatedFiles, error: 'run cancelled' };
|
|
344
|
-
}
|
|
345
|
-
const generatedFiles = await discoverGeneratedFiles(task.directory);
|
|
346
|
-
emitGenerationResult({
|
|
347
|
-
status: 'failed',
|
|
348
|
-
agent: task.agent,
|
|
349
|
-
model,
|
|
350
|
-
attemptCount: generationAttempts,
|
|
351
|
-
tokenCount: tokens - startingTokens,
|
|
352
|
-
durationMs: Date.now() - startedAt,
|
|
353
|
-
generatedFileCount: generatedFiles.length,
|
|
354
|
-
});
|
|
355
|
-
const detailsPath = await writeErrorDetails(task.directory, attemptErrors);
|
|
356
|
-
const error = detailsPath ? `${lastError}\nDetails: ${detailsPath}` : lastError;
|
|
357
|
-
emit('agent_error', tokens, generatedFiles, error);
|
|
358
|
-
return { ...task, attempts, tokens, generatedFiles, error };
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
return mapWithConcurrency(tasks, concurrency, generateTask);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async function promptCodex({
|
|
365
|
-
prompt,
|
|
366
|
-
systemPrompt,
|
|
367
|
-
workingDirectory,
|
|
368
|
-
docsRoot,
|
|
369
|
-
signal,
|
|
370
|
-
}: {
|
|
371
|
-
prompt: string;
|
|
372
|
-
systemPrompt: string;
|
|
373
|
-
workingDirectory: string;
|
|
374
|
-
docsRoot: string;
|
|
375
|
-
signal: AbortSignal;
|
|
376
|
-
}): Promise<{ tokens: number; result: string }> {
|
|
377
|
-
const { Codex } = await loadCodexSdk();
|
|
378
|
-
const thread = new Codex().startThread({
|
|
379
|
-
additionalDirectories: [docsRoot],
|
|
380
|
-
approvalPolicy: 'never',
|
|
381
|
-
networkAccessEnabled: true,
|
|
382
|
-
sandboxMode: 'workspace-write',
|
|
383
|
-
skipGitRepoCheck: true,
|
|
384
|
-
workingDirectory,
|
|
385
|
-
});
|
|
386
|
-
const result = await thread.run(`${systemPrompt}\n\n${prompt}`, { signal });
|
|
387
|
-
const usage = result.usage;
|
|
388
|
-
const tokens = usage
|
|
389
|
-
? usage.input_tokens +
|
|
390
|
-
usage.cached_input_tokens +
|
|
391
|
-
usage.cache_write_input_tokens +
|
|
392
|
-
usage.output_tokens +
|
|
393
|
-
usage.reasoning_output_tokens
|
|
394
|
-
: 0;
|
|
395
|
-
return { tokens, result: result.finalResponse };
|
|
396
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
|
|
3
|
-
import { ensureAgentSdks } from './agentPreflight.js';
|
|
4
|
-
import { discoverCodeBlocks } from './discoverCodeBlocks.js';
|
|
5
|
-
import { executeCodeBlocks } from './executeCodeBlocks.js';
|
|
6
|
-
import { filterSelectedPages } from './filterSelectedPages.js';
|
|
7
|
-
import { checkTestability, generateTestCode } from './generateTestCode.js';
|
|
8
|
-
import { recordTestRun, testRunReportPath } from './runHistory.js';
|
|
9
|
-
import { clearRunSave, writeRunSave } from './runSave.js';
|
|
10
|
-
import { scaffoldTaskDirectories, setupFolders } from './setupFolders.js';
|
|
11
|
-
import type { CodeTestReport, RunCodeTestsOptions } from './types.js';
|
|
12
|
-
|
|
13
|
-
export const DEFAULT_CONCURRENCY = 5;
|
|
14
|
-
export const DEFAULT_COMMAND_TIMEOUT_MS = 2 * 60 * 1_000;
|
|
15
|
-
|
|
16
|
-
export { discoverCodeBlocks } from './discoverCodeBlocks.js';
|
|
17
|
-
export { countReport } from './countReport.js';
|
|
18
|
-
export { loadLatestTestRun, loadTestRuns } from './runHistory.js';
|
|
19
|
-
export { clearRunSave, loadRunSave } from './runSave.js';
|
|
20
|
-
export type {
|
|
21
|
-
CodeTestReport,
|
|
22
|
-
CommandResult,
|
|
23
|
-
CommandStatus,
|
|
24
|
-
DocsPage,
|
|
25
|
-
GeneratedTestManifest,
|
|
26
|
-
HarnessAgent,
|
|
27
|
-
RunCodeTestsOptions,
|
|
28
|
-
RunSave,
|
|
29
|
-
TaskPhase,
|
|
30
|
-
TaskResult,
|
|
31
|
-
TaskStatus,
|
|
32
|
-
TaskUpdate,
|
|
33
|
-
TestGenerationResult,
|
|
34
|
-
TestRunSummary,
|
|
35
|
-
TestRunsIndex,
|
|
36
|
-
} from './types.js';
|
|
37
|
-
|
|
38
|
-
export async function runCodeTests({
|
|
39
|
-
path: docsPath,
|
|
40
|
-
agents,
|
|
41
|
-
model,
|
|
42
|
-
selectedFiles,
|
|
43
|
-
outputDirectory,
|
|
44
|
-
runId: savedRunId,
|
|
45
|
-
concurrency,
|
|
46
|
-
commandTimeoutMs,
|
|
47
|
-
signal,
|
|
48
|
-
onTaskUpdate,
|
|
49
|
-
onGenerationResult,
|
|
50
|
-
}: RunCodeTestsOptions): Promise<CodeTestReport> {
|
|
51
|
-
const startedAt = new Date();
|
|
52
|
-
const uniqueAgents = [...new Set(agents)];
|
|
53
|
-
|
|
54
|
-
const { docsRoot, pages: allPages } = await discoverCodeBlocks(docsPath);
|
|
55
|
-
const pages = filterSelectedPages(allPages, selectedFiles);
|
|
56
|
-
if (pages.length > 0) await ensureAgentSdks(uniqueAgents);
|
|
57
|
-
|
|
58
|
-
const {
|
|
59
|
-
runId,
|
|
60
|
-
runDirectory,
|
|
61
|
-
reportPath: legacyReportPath,
|
|
62
|
-
tasks,
|
|
63
|
-
} = await setupFolders({
|
|
64
|
-
pages,
|
|
65
|
-
agents: uniqueAgents,
|
|
66
|
-
docsRoot,
|
|
67
|
-
outputDirectory,
|
|
68
|
-
runId: savedRunId,
|
|
69
|
-
});
|
|
70
|
-
const reportPath = testRunReportPath(docsRoot, runId);
|
|
71
|
-
await writeRunSave(outputDirectory, {
|
|
72
|
-
version: 1,
|
|
73
|
-
runId,
|
|
74
|
-
agents: uniqueAgents,
|
|
75
|
-
model,
|
|
76
|
-
selectedFiles: pages.map((page) => page.file),
|
|
77
|
-
createdAt: startedAt.toISOString(),
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const checked = await checkTestability({ tasks, runDirectory, signal, onTaskUpdate });
|
|
81
|
-
await scaffoldTaskDirectories(checked.filter((task) => task.testable === true));
|
|
82
|
-
|
|
83
|
-
const generated = await generateTestCode({
|
|
84
|
-
tasks: checked,
|
|
85
|
-
docsRoot,
|
|
86
|
-
model,
|
|
87
|
-
concurrency,
|
|
88
|
-
signal,
|
|
89
|
-
onTaskUpdate,
|
|
90
|
-
onGenerationResult,
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const results = await executeCodeBlocks({
|
|
94
|
-
tasks: generated,
|
|
95
|
-
docsRoot,
|
|
96
|
-
concurrency,
|
|
97
|
-
commandTimeoutMs,
|
|
98
|
-
signal,
|
|
99
|
-
onTaskUpdate,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
const completedAt = new Date();
|
|
103
|
-
const report: CodeTestReport = {
|
|
104
|
-
version: 1,
|
|
105
|
-
runId,
|
|
106
|
-
status: signal.aborted
|
|
107
|
-
? 'cancelled'
|
|
108
|
-
: results.every((task) => task.status === 'passed')
|
|
109
|
-
? 'passed'
|
|
110
|
-
: results.some((task) => task.status === 'passed')
|
|
111
|
-
? 'completed'
|
|
112
|
-
: 'failed',
|
|
113
|
-
docsRoot,
|
|
114
|
-
outputDirectory: runDirectory,
|
|
115
|
-
reportPath,
|
|
116
|
-
agents: uniqueAgents,
|
|
117
|
-
model,
|
|
118
|
-
selectedFiles: pages.map((page) => page.file),
|
|
119
|
-
startedAt: startedAt.toISOString(),
|
|
120
|
-
completedAt: completedAt.toISOString(),
|
|
121
|
-
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
122
|
-
tasks: results,
|
|
123
|
-
};
|
|
124
|
-
await fs.writeFile(legacyReportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
125
|
-
if (!signal.aborted) await clearRunSave(outputDirectory);
|
|
126
|
-
return recordTestRun(docsRoot, report, legacyReportPath);
|
|
127
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
const INSTALL_HINTS: Record<string, string> = {
|
|
2
|
-
'@anthropic-ai/claude-agent-sdk':
|
|
3
|
-
'npm install @anthropic-ai/claude-agent-sdk @anthropic-ai/sdk @modelcontextprotocol/sdk',
|
|
4
|
-
'@openai/codex-sdk': 'npm install @openai/codex-sdk',
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
function isModuleNotFound(error: unknown): boolean {
|
|
8
|
-
if (typeof error !== 'object' || error === null) return false;
|
|
9
|
-
const { code } = error as { code?: unknown };
|
|
10
|
-
return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function missingSdkError(packageName: string): Error {
|
|
14
|
-
return new Error(
|
|
15
|
-
`mint test requires the optional dependency ${packageName}, which is not installed. Install it with: ${INSTALL_HINTS[packageName]}`
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function loadClaudeAgentSdk() {
|
|
20
|
-
try {
|
|
21
|
-
return await import('@anthropic-ai/claude-agent-sdk');
|
|
22
|
-
} catch (error) {
|
|
23
|
-
if (isModuleNotFound(error)) throw missingSdkError('@anthropic-ai/claude-agent-sdk');
|
|
24
|
-
throw error;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function loadCodexSdk() {
|
|
29
|
-
try {
|
|
30
|
-
return await import('@openai/codex-sdk');
|
|
31
|
-
} catch (error) {
|
|
32
|
-
if (isModuleNotFound(error)) throw missingSdkError('@openai/codex-sdk');
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export async function mapWithConcurrency<T, R, A extends unknown[]>(
|
|
2
|
-
items: T[],
|
|
3
|
-
concurrency: number,
|
|
4
|
-
fn: (item: T, ...args: A) => Promise<R>,
|
|
5
|
-
...args: A
|
|
6
|
-
): Promise<R[]> {
|
|
7
|
-
const results: R[] = new Array(items.length);
|
|
8
|
-
let nextIndex = 0;
|
|
9
|
-
const worker = async (): Promise<void> => {
|
|
10
|
-
for (;;) {
|
|
11
|
-
const index = nextIndex++;
|
|
12
|
-
const item = items[index];
|
|
13
|
-
if (index >= items.length || item === undefined) return;
|
|
14
|
-
results[index] = await fn(item, ...args);
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
await Promise.all(
|
|
18
|
-
Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, () => worker())
|
|
19
|
-
);
|
|
20
|
-
return results;
|
|
21
|
-
}
|