@mintlify/cli 4.0.1434 → 4.0.1438
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/agent-harness/buildTaskPrompt.js +76 -0
- package/bin/agent-harness/discoverCodeBlocks.js +101 -0
- package/bin/agent-harness/executeCodeBlocks.js +220 -0
- package/bin/agent-harness/filterSelectedPages.js +10 -0
- package/bin/agent-harness/generateTestCode.js +293 -0
- package/bin/agent-harness/index.js +85 -0
- package/bin/agent-harness/loadAgentSdk.js +46 -0
- package/bin/agent-harness/mapWithConcurrency.js +26 -0
- package/bin/agent-harness/runSave.js +47 -0
- package/bin/agent-harness/setupFolders.js +59 -0
- package/bin/agent-harness/tasks/checkTestability.js +226 -0
- package/bin/agent-harness/types.js +35 -0
- package/bin/cli.js +10 -4
- package/bin/mintTest.js +96 -0
- package/bin/mintTestNav.js +35 -0
- package/bin/mintTestTree.js +94 -0
- package/bin/mintTestUi.js +610 -0
- package/bin/status.js +11 -4
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +13 -9
- package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +58 -0
- package/src/agent-harness/buildTaskPrompt.ts +82 -0
- package/src/agent-harness/discoverCodeBlocks.ts +90 -0
- package/src/agent-harness/executeCodeBlocks.ts +285 -0
- package/src/agent-harness/filterSelectedPages.ts +14 -0
- package/src/agent-harness/generateTestCode.ts +332 -0
- package/src/agent-harness/index.ts +109 -0
- package/src/agent-harness/loadAgentSdk.ts +35 -0
- package/src/agent-harness/mapWithConcurrency.ts +21 -0
- package/src/agent-harness/runSave.ts +38 -0
- package/src/agent-harness/setupFolders.ts +73 -0
- package/src/agent-harness/tasks/checkTestability.ts +247 -0
- package/src/agent-harness/types.ts +152 -0
- package/src/cli.tsx +15 -9
- package/src/mintTest.tsx +102 -0
- package/src/mintTestNav.ts +31 -0
- package/src/mintTestTree.ts +134 -0
- package/src/mintTestUi.tsx +988 -0
- package/src/status.tsx +11 -4
|
@@ -0,0 +1,332 @@
|
|
|
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 { mapWithConcurrency } from './mapWithConcurrency.js';
|
|
7
|
+
import { checkFileTestability } from './tasks/checkTestability.js';
|
|
8
|
+
import {
|
|
9
|
+
manifestSchema,
|
|
10
|
+
type GeneratedTask,
|
|
11
|
+
type GeneratedTestManifest,
|
|
12
|
+
type TaskUpdate,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
const MANIFEST_FILENAME = 'mint-test.json';
|
|
16
|
+
const SAFE_EXECUTABLES = new Set([
|
|
17
|
+
'bun',
|
|
18
|
+
'bunx',
|
|
19
|
+
'bundle',
|
|
20
|
+
'cargo',
|
|
21
|
+
'composer',
|
|
22
|
+
'deno',
|
|
23
|
+
'dotnet',
|
|
24
|
+
'go',
|
|
25
|
+
'gradle',
|
|
26
|
+
'java',
|
|
27
|
+
'javac',
|
|
28
|
+
'mvn',
|
|
29
|
+
'node',
|
|
30
|
+
'npm',
|
|
31
|
+
'npx',
|
|
32
|
+
'php',
|
|
33
|
+
'pip',
|
|
34
|
+
'pip3',
|
|
35
|
+
'pnpm',
|
|
36
|
+
'python',
|
|
37
|
+
'python3',
|
|
38
|
+
'pytest',
|
|
39
|
+
'ruby',
|
|
40
|
+
'rustc',
|
|
41
|
+
'uv',
|
|
42
|
+
'vitest',
|
|
43
|
+
'yarn',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
async function promptClaude({
|
|
47
|
+
prompt,
|
|
48
|
+
systemPrompt,
|
|
49
|
+
model,
|
|
50
|
+
workingDirectory,
|
|
51
|
+
docsRoot,
|
|
52
|
+
tools,
|
|
53
|
+
signal,
|
|
54
|
+
}: {
|
|
55
|
+
prompt: string;
|
|
56
|
+
systemPrompt: string;
|
|
57
|
+
model: string;
|
|
58
|
+
workingDirectory: string;
|
|
59
|
+
docsRoot: string;
|
|
60
|
+
tools: string[];
|
|
61
|
+
signal: AbortSignal;
|
|
62
|
+
}): Promise<{ tokens: number; result: string }> {
|
|
63
|
+
const { query } = await loadClaudeAgentSdk();
|
|
64
|
+
const abortController = new AbortController();
|
|
65
|
+
const abort = () => abortController.abort(signal.reason);
|
|
66
|
+
if (signal.aborted) abort();
|
|
67
|
+
else signal.addEventListener('abort', abort, { once: true });
|
|
68
|
+
|
|
69
|
+
const session = query({
|
|
70
|
+
prompt,
|
|
71
|
+
options: {
|
|
72
|
+
abortController,
|
|
73
|
+
additionalDirectories: [docsRoot],
|
|
74
|
+
allowedTools: tools,
|
|
75
|
+
cwd: workingDirectory,
|
|
76
|
+
model,
|
|
77
|
+
permissionMode: 'dontAsk',
|
|
78
|
+
persistSession: false,
|
|
79
|
+
settingSources: [],
|
|
80
|
+
strictMcpConfig: true,
|
|
81
|
+
systemPrompt,
|
|
82
|
+
tools,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
for await (const message of session) {
|
|
88
|
+
if (message.type !== 'result') continue;
|
|
89
|
+
if (message.subtype !== 'success' || message.is_error) {
|
|
90
|
+
const details = message.subtype === 'success' ? message.result : message.errors.join('; ');
|
|
91
|
+
throw new Error(details || `Claude stopped with ${message.subtype}`);
|
|
92
|
+
}
|
|
93
|
+
const usage = message.usage;
|
|
94
|
+
return {
|
|
95
|
+
tokens:
|
|
96
|
+
usage.input_tokens +
|
|
97
|
+
usage.output_tokens +
|
|
98
|
+
usage.cache_creation_input_tokens +
|
|
99
|
+
usage.cache_read_input_tokens,
|
|
100
|
+
result: message.result,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
throw new Error('Claude finished without returning a result');
|
|
104
|
+
} finally {
|
|
105
|
+
signal.removeEventListener('abort', abort);
|
|
106
|
+
session.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const AGENT_TOOLS = ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash'];
|
|
111
|
+
|
|
112
|
+
function isPathInside(root: string, candidate: string): boolean {
|
|
113
|
+
const relative = path.relative(root, candidate);
|
|
114
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function validateManifest(
|
|
118
|
+
manifest: GeneratedTestManifest,
|
|
119
|
+
directory: string
|
|
120
|
+
): Promise<string[]> {
|
|
121
|
+
const errors: string[] = [];
|
|
122
|
+
const realDirectory = await fs.realpath(directory);
|
|
123
|
+
|
|
124
|
+
for (const file of manifest.generatedFiles) {
|
|
125
|
+
if (path.isAbsolute(file)) {
|
|
126
|
+
errors.push(`Generated file path must be relative: ${file}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const resolved = path.resolve(directory, file);
|
|
130
|
+
if (!isPathInside(directory, resolved)) {
|
|
131
|
+
errors.push(`Generated file leaves its task: ${file}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const realPath = await fs.realpath(resolved).catch(() => undefined);
|
|
135
|
+
if (!realPath || !isPathInside(realDirectory, realPath)) {
|
|
136
|
+
errors.push(`Generated file leaves its task through a link: ${file}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const stats = await fs.stat(realPath).catch(() => undefined);
|
|
140
|
+
if (!stats?.isFile()) errors.push(`Generated file does not exist: ${file}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const commandIds = new Set<string>();
|
|
144
|
+
|
|
145
|
+
for (const command of [...manifest.setupCommands, ...manifest.testCommands]) {
|
|
146
|
+
if (commandIds.has(command.id)) errors.push(`Duplicate command ID: ${command.id}`);
|
|
147
|
+
commandIds.add(command.id);
|
|
148
|
+
if (path.isAbsolute(command.executable) || command.executable.split(/[\\/]/).includes('..')) {
|
|
149
|
+
errors.push(`Unsafe command executable: ${command.executable}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!SAFE_EXECUTABLES.has(command.executable) && !command.executable.startsWith('./')) {
|
|
153
|
+
errors.push(`Unsupported command executable: ${command.executable}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (command.executable.startsWith('./')) {
|
|
157
|
+
const executablePath = await fs
|
|
158
|
+
.realpath(path.resolve(directory, command.executable))
|
|
159
|
+
.catch(() => undefined);
|
|
160
|
+
const stats = executablePath
|
|
161
|
+
? await fs.stat(executablePath).catch(() => undefined)
|
|
162
|
+
: undefined;
|
|
163
|
+
if (!executablePath || !isPathInside(realDirectory, executablePath) || !stats?.isFile()) {
|
|
164
|
+
errors.push(`Command executable leaves its task or is not a file: ${command.executable}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const cwd = command.cwd ?? '.';
|
|
168
|
+
if (path.isAbsolute(cwd)) {
|
|
169
|
+
errors.push(`Command cwd must be relative: ${cwd}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const resolvedCwd = path.resolve(directory, cwd);
|
|
173
|
+
const realCwd = await fs.realpath(resolvedCwd).catch(() => undefined);
|
|
174
|
+
const stats = realCwd ? await fs.stat(realCwd).catch(() => undefined) : undefined;
|
|
175
|
+
if (
|
|
176
|
+
!isPathInside(directory, resolvedCwd) ||
|
|
177
|
+
!realCwd ||
|
|
178
|
+
!isPathInside(realDirectory, realCwd) ||
|
|
179
|
+
!stats?.isDirectory()
|
|
180
|
+
) {
|
|
181
|
+
errors.push(`Command cwd is missing or leaves its task: ${cwd}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return errors;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function readValidManifest(directory: string): Promise<GeneratedTestManifest | null> {
|
|
189
|
+
try {
|
|
190
|
+
const manifest = manifestSchema.parse(
|
|
191
|
+
JSON.parse(await fs.readFile(path.join(directory, MANIFEST_FILENAME), 'utf8'))
|
|
192
|
+
);
|
|
193
|
+
const errors = await validateManifest(manifest, directory);
|
|
194
|
+
return errors.length === 0 ? manifest : null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const CHECK_CONCURRENCY = 20;
|
|
201
|
+
|
|
202
|
+
export async function checkTestability({
|
|
203
|
+
tasks,
|
|
204
|
+
signal,
|
|
205
|
+
onTaskUpdate,
|
|
206
|
+
}: {
|
|
207
|
+
tasks: GeneratedTask[];
|
|
208
|
+
signal: AbortSignal;
|
|
209
|
+
onTaskUpdate: (update: TaskUpdate) => void;
|
|
210
|
+
}): Promise<GeneratedTask[]> {
|
|
211
|
+
const folderOrdered = [...tasks].sort(
|
|
212
|
+
(left, right) =>
|
|
213
|
+
path.dirname(left.file).localeCompare(path.dirname(right.file)) ||
|
|
214
|
+
left.file.localeCompare(right.file) ||
|
|
215
|
+
left.agent.localeCompare(right.agent)
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return mapWithConcurrency(folderOrdered, CHECK_CONCURRENCY, checkFileTestability, {
|
|
219
|
+
signal,
|
|
220
|
+
onTaskUpdate,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function generateTestCode({
|
|
225
|
+
tasks,
|
|
226
|
+
docsRoot,
|
|
227
|
+
model,
|
|
228
|
+
concurrency,
|
|
229
|
+
signal,
|
|
230
|
+
onTaskUpdate,
|
|
231
|
+
}: {
|
|
232
|
+
tasks: GeneratedTask[];
|
|
233
|
+
docsRoot: string;
|
|
234
|
+
model: string;
|
|
235
|
+
concurrency: number;
|
|
236
|
+
signal: AbortSignal;
|
|
237
|
+
onTaskUpdate: (update: TaskUpdate) => void;
|
|
238
|
+
}): Promise<GeneratedTask[]> {
|
|
239
|
+
const generateTask = async (task: GeneratedTask): Promise<GeneratedTask> => {
|
|
240
|
+
if (task.error || !task.testable) return task;
|
|
241
|
+
const emit = (phase: TaskUpdate['phase'], tokens: number) =>
|
|
242
|
+
onTaskUpdate({ id: task.id, agent: task.agent, file: task.file, phase, tokens });
|
|
243
|
+
let attempts = task.attempts;
|
|
244
|
+
let tokens = task.tokens;
|
|
245
|
+
let lastError = 'run cancelled';
|
|
246
|
+
|
|
247
|
+
const existing = await readValidManifest(task.directory);
|
|
248
|
+
if (existing) {
|
|
249
|
+
emit('generated', tokens);
|
|
250
|
+
return { ...task, manifest: existing };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (let attempt = 1; attempt <= 2 && !signal.aborted; attempt++) {
|
|
254
|
+
attempts += 1;
|
|
255
|
+
if (attempt === 1) emit('generating', tokens);
|
|
256
|
+
try {
|
|
257
|
+
const prompt = buildTaskPrompt(docsRoot, task.file, task.directory);
|
|
258
|
+
const reply =
|
|
259
|
+
task.agent === 'claude'
|
|
260
|
+
? await promptClaude({
|
|
261
|
+
prompt,
|
|
262
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
263
|
+
model,
|
|
264
|
+
workingDirectory: task.directory,
|
|
265
|
+
docsRoot,
|
|
266
|
+
tools: AGENT_TOOLS,
|
|
267
|
+
signal,
|
|
268
|
+
})
|
|
269
|
+
: await promptCodex({
|
|
270
|
+
prompt,
|
|
271
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
272
|
+
workingDirectory: task.directory,
|
|
273
|
+
docsRoot,
|
|
274
|
+
signal,
|
|
275
|
+
});
|
|
276
|
+
tokens += reply.tokens;
|
|
277
|
+
const manifest = manifestSchema.parse(
|
|
278
|
+
JSON.parse(await fs.readFile(path.join(task.directory, MANIFEST_FILENAME), 'utf8'))
|
|
279
|
+
);
|
|
280
|
+
const errors = await validateManifest(manifest, task.directory);
|
|
281
|
+
if (errors.length > 0) throw new Error(errors.join('\n'));
|
|
282
|
+
|
|
283
|
+
emit('generated', tokens);
|
|
284
|
+
return { ...task, attempts, tokens, manifest };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (signal.aborted) {
|
|
290
|
+
emit('cancelled', tokens);
|
|
291
|
+
return { ...task, attempts, tokens, error: 'run cancelled' };
|
|
292
|
+
}
|
|
293
|
+
emit('agent_error', tokens);
|
|
294
|
+
return { ...task, attempts, tokens, error: lastError };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return mapWithConcurrency(tasks, concurrency, generateTask);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function promptCodex({
|
|
301
|
+
prompt,
|
|
302
|
+
systemPrompt,
|
|
303
|
+
workingDirectory,
|
|
304
|
+
docsRoot,
|
|
305
|
+
signal,
|
|
306
|
+
}: {
|
|
307
|
+
prompt: string;
|
|
308
|
+
systemPrompt: string;
|
|
309
|
+
workingDirectory: string;
|
|
310
|
+
docsRoot: string;
|
|
311
|
+
signal: AbortSignal;
|
|
312
|
+
}): Promise<{ tokens: number; result: string }> {
|
|
313
|
+
const { Codex } = await loadCodexSdk();
|
|
314
|
+
const thread = new Codex().startThread({
|
|
315
|
+
additionalDirectories: [docsRoot],
|
|
316
|
+
approvalPolicy: 'never',
|
|
317
|
+
networkAccessEnabled: true,
|
|
318
|
+
sandboxMode: 'workspace-write',
|
|
319
|
+
skipGitRepoCheck: true,
|
|
320
|
+
workingDirectory,
|
|
321
|
+
});
|
|
322
|
+
const result = await thread.run(`${systemPrompt}\n\n${prompt}`, { signal });
|
|
323
|
+
const usage = result.usage;
|
|
324
|
+
const tokens = usage
|
|
325
|
+
? usage.input_tokens +
|
|
326
|
+
usage.cached_input_tokens +
|
|
327
|
+
usage.cache_write_input_tokens +
|
|
328
|
+
usage.output_tokens +
|
|
329
|
+
usage.reasoning_output_tokens
|
|
330
|
+
: 0;
|
|
331
|
+
return { tokens, result: result.finalResponse };
|
|
332
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { discoverCodeBlocks } from './discoverCodeBlocks.js';
|
|
4
|
+
import { executeCodeBlocks } from './executeCodeBlocks.js';
|
|
5
|
+
import { filterSelectedPages } from './filterSelectedPages.js';
|
|
6
|
+
import { checkTestability, generateTestCode } from './generateTestCode.js';
|
|
7
|
+
import { clearRunSave, writeRunSave } from './runSave.js';
|
|
8
|
+
import { setupFolders } from './setupFolders.js';
|
|
9
|
+
import type { CodeTestReport, RunCodeTestsOptions } from './types.js';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CONCURRENCY = 5;
|
|
12
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 2 * 60 * 1_000;
|
|
13
|
+
|
|
14
|
+
export { discoverCodeBlocks } from './discoverCodeBlocks.js';
|
|
15
|
+
export { clearRunSave, loadRunSave } from './runSave.js';
|
|
16
|
+
export type {
|
|
17
|
+
CodeTestReport,
|
|
18
|
+
CommandResult,
|
|
19
|
+
CommandStatus,
|
|
20
|
+
DocsPage,
|
|
21
|
+
GeneratedTestManifest,
|
|
22
|
+
HarnessAgent,
|
|
23
|
+
RunCodeTestsOptions,
|
|
24
|
+
RunSave,
|
|
25
|
+
TaskPhase,
|
|
26
|
+
TaskResult,
|
|
27
|
+
TaskStatus,
|
|
28
|
+
TaskUpdate,
|
|
29
|
+
} from './types.js';
|
|
30
|
+
|
|
31
|
+
export async function runCodeTests({
|
|
32
|
+
path: docsPath,
|
|
33
|
+
agents,
|
|
34
|
+
model,
|
|
35
|
+
selectedFiles,
|
|
36
|
+
outputDirectory,
|
|
37
|
+
runId: savedRunId,
|
|
38
|
+
concurrency,
|
|
39
|
+
commandTimeoutMs,
|
|
40
|
+
signal,
|
|
41
|
+
onTaskUpdate,
|
|
42
|
+
}: RunCodeTestsOptions): Promise<CodeTestReport> {
|
|
43
|
+
const startedAt = new Date();
|
|
44
|
+
const uniqueAgents = [...new Set(agents)];
|
|
45
|
+
|
|
46
|
+
const { docsRoot, pages: allPages } = await discoverCodeBlocks(docsPath);
|
|
47
|
+
const pages = filterSelectedPages(allPages, selectedFiles);
|
|
48
|
+
|
|
49
|
+
const { runId, runDirectory, reportPath, tasks } = await setupFolders({
|
|
50
|
+
pages,
|
|
51
|
+
agents: uniqueAgents,
|
|
52
|
+
docsRoot,
|
|
53
|
+
outputDirectory,
|
|
54
|
+
runId: savedRunId,
|
|
55
|
+
});
|
|
56
|
+
await writeRunSave(outputDirectory, {
|
|
57
|
+
version: 1,
|
|
58
|
+
runId,
|
|
59
|
+
agents: uniqueAgents,
|
|
60
|
+
model,
|
|
61
|
+
selectedFiles: pages.map((page) => page.file),
|
|
62
|
+
createdAt: startedAt.toISOString(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const checked = await checkTestability({ tasks, signal, onTaskUpdate });
|
|
66
|
+
|
|
67
|
+
const generated = await generateTestCode({
|
|
68
|
+
tasks: checked,
|
|
69
|
+
docsRoot,
|
|
70
|
+
model,
|
|
71
|
+
concurrency,
|
|
72
|
+
signal,
|
|
73
|
+
onTaskUpdate,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const results = await executeCodeBlocks({
|
|
77
|
+
tasks: generated,
|
|
78
|
+
docsRoot,
|
|
79
|
+
concurrency,
|
|
80
|
+
commandTimeoutMs,
|
|
81
|
+
signal,
|
|
82
|
+
onTaskUpdate,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const completedAt = new Date();
|
|
86
|
+
const report: CodeTestReport = {
|
|
87
|
+
version: 1,
|
|
88
|
+
runId,
|
|
89
|
+
status: signal.aborted
|
|
90
|
+
? 'cancelled'
|
|
91
|
+
: results.every((task) => task.status === 'passed')
|
|
92
|
+
? 'passed'
|
|
93
|
+
: results.some((task) => task.status === 'passed')
|
|
94
|
+
? 'completed'
|
|
95
|
+
: 'failed',
|
|
96
|
+
docsRoot,
|
|
97
|
+
outputDirectory: runDirectory,
|
|
98
|
+
reportPath,
|
|
99
|
+
agents: uniqueAgents,
|
|
100
|
+
selectedFiles: pages.map((page) => page.file),
|
|
101
|
+
startedAt: startedAt.toISOString(),
|
|
102
|
+
completedAt: completedAt.toISOString(),
|
|
103
|
+
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
104
|
+
tasks: results,
|
|
105
|
+
};
|
|
106
|
+
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
107
|
+
if (!signal.aborted) await clearRunSave(outputDirectory);
|
|
108
|
+
return report;
|
|
109
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import type { RunSave } from './types.js';
|
|
6
|
+
|
|
7
|
+
const runSaveSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
version: z.literal(1),
|
|
10
|
+
runId: z.string().regex(/^[A-Za-z0-9._-]+$/),
|
|
11
|
+
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
12
|
+
model: z.string().min(1),
|
|
13
|
+
selectedFiles: z.array(z.string().min(1)),
|
|
14
|
+
createdAt: z.string(),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
|
|
18
|
+
function savePath(outputDirectory: string): string {
|
|
19
|
+
return path.join(outputDirectory, 'save.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadRunSave(outputDirectory: string): Promise<RunSave | null> {
|
|
23
|
+
try {
|
|
24
|
+
const content = await fs.readFile(savePath(outputDirectory), 'utf8');
|
|
25
|
+
return runSaveSchema.parse(JSON.parse(content));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeRunSave(outputDirectory: string, save: RunSave): Promise<void> {
|
|
32
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
33
|
+
await fs.writeFile(savePath(outputDirectory), `${JSON.stringify(save, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function clearRunSave(outputDirectory: string): Promise<void> {
|
|
37
|
+
await fs.unlink(savePath(outputDirectory)).catch(() => undefined);
|
|
38
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { DocsPage, GeneratedTask, HarnessAgent } from './types.js';
|
|
6
|
+
|
|
7
|
+
const PACKAGE_JSON = `${JSON.stringify(
|
|
8
|
+
{
|
|
9
|
+
name: 'mint-test-task',
|
|
10
|
+
private: true,
|
|
11
|
+
type: 'module',
|
|
12
|
+
scripts: { test: 'node --test tests/' },
|
|
13
|
+
},
|
|
14
|
+
null,
|
|
15
|
+
2
|
|
16
|
+
)}\n`;
|
|
17
|
+
|
|
18
|
+
async function scaffoldTaskDirectory(directory: string): Promise<void> {
|
|
19
|
+
await fs.mkdir(path.join(directory, 'tests'), { recursive: true });
|
|
20
|
+
await fs
|
|
21
|
+
.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' })
|
|
22
|
+
.catch(() => {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function setupFolders({
|
|
26
|
+
pages,
|
|
27
|
+
agents,
|
|
28
|
+
docsRoot,
|
|
29
|
+
outputDirectory,
|
|
30
|
+
runId: savedRunId,
|
|
31
|
+
}: {
|
|
32
|
+
pages: DocsPage[];
|
|
33
|
+
agents: HarnessAgent[];
|
|
34
|
+
docsRoot: string;
|
|
35
|
+
outputDirectory: string;
|
|
36
|
+
runId: string | null;
|
|
37
|
+
}): Promise<{ runId: string; runDirectory: string; reportPath: string; tasks: GeneratedTask[] }> {
|
|
38
|
+
const runId =
|
|
39
|
+
savedRunId ??
|
|
40
|
+
`${new Date()
|
|
41
|
+
.toISOString()
|
|
42
|
+
.replace(/[-:TZ.]/g, '')
|
|
43
|
+
.slice(0, 14)}-${randomBytes(4).toString('hex')}`;
|
|
44
|
+
const runDirectory = path.join(path.resolve(outputDirectory), runId);
|
|
45
|
+
const reportPath = path.join(runDirectory, 'report.json');
|
|
46
|
+
await fs.mkdir(runDirectory, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const tasks: GeneratedTask[] = [];
|
|
49
|
+
for (const agent of agents) {
|
|
50
|
+
for (const [pageIndex, page] of pages.entries()) {
|
|
51
|
+
const slug = page.file
|
|
52
|
+
.replace(/\.[^.]+$/, '')
|
|
53
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
54
|
+
.replace(/^-|-$/g, '')
|
|
55
|
+
.slice(0, 56);
|
|
56
|
+
tasks.push({
|
|
57
|
+
id: `${agent}:${page.file}`,
|
|
58
|
+
agent,
|
|
59
|
+
docsRoot,
|
|
60
|
+
file: page.file,
|
|
61
|
+
directory: path.join(
|
|
62
|
+
runDirectory,
|
|
63
|
+
agent,
|
|
64
|
+
`${String(pageIndex + 1).padStart(3, '0')}-${slug || 'page'}`
|
|
65
|
+
),
|
|
66
|
+
attempts: 0,
|
|
67
|
+
tokens: 0,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const task of tasks) await scaffoldTaskDirectory(task.directory);
|
|
72
|
+
return { runId, runDirectory, reportPath, tasks };
|
|
73
|
+
}
|