@mintlify/cli 4.0.1465 → 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 +7 -7
- 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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { PREVIEW_BYTE_LIMIT, readFilePreview } from '../src/mintTestFilePreview.js';
|
|
6
|
+
|
|
7
|
+
let temporaryDirectory: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-preview-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await fs.rm(temporaryDirectory, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('reads at most the preview limit plus the truncation marker byte', async () => {
|
|
18
|
+
const filePath = path.join(temporaryDirectory, 'large.txt');
|
|
19
|
+
await fs.writeFile(filePath, 'a'.repeat(PREVIEW_BYTE_LIMIT + 10_000));
|
|
20
|
+
|
|
21
|
+
const preview = await readFilePreview(filePath);
|
|
22
|
+
|
|
23
|
+
expect(preview).toEqual({
|
|
24
|
+
binary: false,
|
|
25
|
+
text: 'a'.repeat(PREVIEW_BYTE_LIMIT),
|
|
26
|
+
truncated: true,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects binary previews', async () => {
|
|
31
|
+
const filePath = path.join(temporaryDirectory, 'binary.dat');
|
|
32
|
+
await fs.writeFile(filePath, new Uint8Array([1, 0, 2]));
|
|
33
|
+
|
|
34
|
+
await expect(readFilePreview(filePath)).resolves.toEqual({ binary: true });
|
|
35
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { executeCodeBlocks } from '../src/agent-harness/executeCodeBlocks.js';
|
|
6
|
+
import { generateTestCode } from '../src/agent-harness/generateTestCode.js';
|
|
7
|
+
import type { GeneratedTask, TaskUpdate } from '../src/agent-harness/types.js';
|
|
8
|
+
|
|
9
|
+
const agentMocks = vi.hoisted(() => ({ codexRun: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/agent-harness/loadAgentSdk.js', () => ({
|
|
12
|
+
loadCodexSdk: vi.fn(async () => ({
|
|
13
|
+
Codex: class {
|
|
14
|
+
startThread() {
|
|
15
|
+
return { run: agentMocks.codexRun };
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
})),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const temporaryDirectories: string[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
await Promise.all(
|
|
26
|
+
temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true }))
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('includes absolute generated file paths in updates and results', async () => {
|
|
31
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-output-'));
|
|
32
|
+
temporaryDirectories.push(directory);
|
|
33
|
+
const generatedFile = path.join(directory, 'tests', 'generated.test.ts');
|
|
34
|
+
await fs.mkdir(path.dirname(generatedFile), { recursive: true });
|
|
35
|
+
await fs.writeFile(generatedFile, 'export const generated = true;\n');
|
|
36
|
+
const task: GeneratedTask = {
|
|
37
|
+
id: 'claude:guide.mdx',
|
|
38
|
+
agent: 'claude',
|
|
39
|
+
docsRoot: directory,
|
|
40
|
+
file: 'guide.mdx',
|
|
41
|
+
directory,
|
|
42
|
+
attempts: 1,
|
|
43
|
+
tokens: 10,
|
|
44
|
+
testable: true,
|
|
45
|
+
manifest: {
|
|
46
|
+
version: 1,
|
|
47
|
+
generatedFiles: ['tests/generated.test.ts'],
|
|
48
|
+
setupCommands: [],
|
|
49
|
+
testCommands: [],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const updates: TaskUpdate[] = [];
|
|
53
|
+
|
|
54
|
+
const results = await executeCodeBlocks({
|
|
55
|
+
tasks: [task],
|
|
56
|
+
docsRoot: directory,
|
|
57
|
+
concurrency: 1,
|
|
58
|
+
commandTimeoutMs: 1_000,
|
|
59
|
+
signal: new AbortController().signal,
|
|
60
|
+
onTaskUpdate: (update) => updates.push(update),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(results).toHaveLength(1);
|
|
64
|
+
expect(results[0]?.generatedFiles).toEqual([generatedFile]);
|
|
65
|
+
expect(updates.map((update) => update.generatedFiles)).toEqual([
|
|
66
|
+
[generatedFile],
|
|
67
|
+
[generatedFile],
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('includes partial generated files when generation fails', async () => {
|
|
72
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-output-'));
|
|
73
|
+
temporaryDirectories.push(directory);
|
|
74
|
+
const generatedFile = path.join(directory, 'tests', 'partial.test.ts');
|
|
75
|
+
await fs.mkdir(path.dirname(generatedFile), { recursive: true });
|
|
76
|
+
await fs.writeFile(path.join(directory, 'package.json'), '{}\n');
|
|
77
|
+
agentMocks.codexRun.mockImplementation(async () => {
|
|
78
|
+
await fs.writeFile(generatedFile, 'export const partial = true;\n');
|
|
79
|
+
throw new Error('generation failed');
|
|
80
|
+
});
|
|
81
|
+
const updates: TaskUpdate[] = [];
|
|
82
|
+
|
|
83
|
+
const generated = await generateTestCode({
|
|
84
|
+
tasks: [generatedTask(directory)],
|
|
85
|
+
docsRoot: directory,
|
|
86
|
+
model: 'unused',
|
|
87
|
+
concurrency: 1,
|
|
88
|
+
signal: new AbortController().signal,
|
|
89
|
+
onTaskUpdate: (update) => updates.push(update),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(updates.at(-1)).toMatchObject({
|
|
93
|
+
phase: 'agent_error',
|
|
94
|
+
generatedFiles: [generatedFile],
|
|
95
|
+
});
|
|
96
|
+
const results = await executeCodeBlocks({
|
|
97
|
+
tasks: generated,
|
|
98
|
+
docsRoot: directory,
|
|
99
|
+
concurrency: 1,
|
|
100
|
+
commandTimeoutMs: 1_000,
|
|
101
|
+
signal: new AbortController().signal,
|
|
102
|
+
onTaskUpdate: (update) => updates.push(update),
|
|
103
|
+
});
|
|
104
|
+
expect(results[0]?.generatedFiles).toEqual([generatedFile]);
|
|
105
|
+
expect(updates.at(-1)).toMatchObject({
|
|
106
|
+
phase: 'agent_error',
|
|
107
|
+
generatedFiles: [generatedFile],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('includes partial generated files when generation is cancelled', async () => {
|
|
112
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-output-'));
|
|
113
|
+
temporaryDirectories.push(directory);
|
|
114
|
+
const generatedFile = path.join(directory, 'tests', 'partial.test.ts');
|
|
115
|
+
await fs.mkdir(path.dirname(generatedFile), { recursive: true });
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
agentMocks.codexRun.mockImplementation(async () => {
|
|
118
|
+
await fs.writeFile(generatedFile, 'export const partial = true;\n');
|
|
119
|
+
controller.abort();
|
|
120
|
+
throw new Error('cancelled');
|
|
121
|
+
});
|
|
122
|
+
const updates: TaskUpdate[] = [];
|
|
123
|
+
|
|
124
|
+
const generated = await generateTestCode({
|
|
125
|
+
tasks: [generatedTask(directory)],
|
|
126
|
+
docsRoot: directory,
|
|
127
|
+
model: 'unused',
|
|
128
|
+
concurrency: 1,
|
|
129
|
+
signal: controller.signal,
|
|
130
|
+
onTaskUpdate: (update) => updates.push(update),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(updates.at(-1)).toMatchObject({
|
|
134
|
+
phase: 'cancelled',
|
|
135
|
+
generatedFiles: [generatedFile],
|
|
136
|
+
});
|
|
137
|
+
const results = await executeCodeBlocks({
|
|
138
|
+
tasks: generated,
|
|
139
|
+
docsRoot: directory,
|
|
140
|
+
concurrency: 1,
|
|
141
|
+
commandTimeoutMs: 1_000,
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
onTaskUpdate: (update) => updates.push(update),
|
|
144
|
+
});
|
|
145
|
+
expect(results[0]?.generatedFiles).toEqual([generatedFile]);
|
|
146
|
+
expect(updates.at(-1)).toMatchObject({
|
|
147
|
+
phase: 'cancelled',
|
|
148
|
+
generatedFiles: [generatedFile],
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it.each([
|
|
153
|
+
{ outcome: 'fails', aborted: false, status: 'agent_error', error: 'generation failed' },
|
|
154
|
+
{ outcome: 'is cancelled', aborted: true, status: 'cancelled', error: 'run cancelled' },
|
|
155
|
+
] as const)(
|
|
156
|
+
'forwards partial generated files through execution when generation $outcome',
|
|
157
|
+
async ({ aborted, status, error }) => {
|
|
158
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-output-'));
|
|
159
|
+
temporaryDirectories.push(directory);
|
|
160
|
+
const generatedFile = path.join(directory, 'tests', 'partial.test.ts');
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
if (aborted) controller.abort();
|
|
163
|
+
const updates: TaskUpdate[] = [];
|
|
164
|
+
|
|
165
|
+
const results = await executeCodeBlocks({
|
|
166
|
+
tasks: [{ ...generatedTask(directory), error, generatedFiles: [generatedFile] }],
|
|
167
|
+
docsRoot: directory,
|
|
168
|
+
concurrency: 1,
|
|
169
|
+
commandTimeoutMs: 1_000,
|
|
170
|
+
signal: controller.signal,
|
|
171
|
+
onTaskUpdate: (update) => updates.push(update),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(results).toEqual([
|
|
175
|
+
expect.objectContaining({ status, error, generatedFiles: [generatedFile], commands: [] }),
|
|
176
|
+
]);
|
|
177
|
+
expect(updates).toEqual([
|
|
178
|
+
expect.objectContaining({ phase: status, generatedFiles: [generatedFile] }),
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
function generatedTask(directory: string): GeneratedTask {
|
|
184
|
+
return {
|
|
185
|
+
id: 'codex:guide.mdx',
|
|
186
|
+
agent: 'codex',
|
|
187
|
+
docsRoot: directory,
|
|
188
|
+
file: 'guide.mdx',
|
|
189
|
+
directory,
|
|
190
|
+
attempts: 0,
|
|
191
|
+
tokens: 0,
|
|
192
|
+
testable: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
loadLatestTestRun,
|
|
7
|
+
loadTestRuns,
|
|
8
|
+
recordTestRun,
|
|
9
|
+
testRunReportPath,
|
|
10
|
+
testRunsIndexPath,
|
|
11
|
+
writeTestRunHistory,
|
|
12
|
+
} from '../src/agent-harness/runHistory.js';
|
|
13
|
+
import type { CodeTestReport } from '../src/agent-harness/types.js';
|
|
14
|
+
|
|
15
|
+
const temporaryDirectories: string[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(
|
|
19
|
+
temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true }))
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeReport(docsRoot: string, runId: string, completedAt: string): CodeTestReport {
|
|
24
|
+
return {
|
|
25
|
+
version: 1,
|
|
26
|
+
runId,
|
|
27
|
+
status: 'passed',
|
|
28
|
+
docsRoot,
|
|
29
|
+
outputDirectory: path.join(docsRoot, 'tests', 'mint-test', runId),
|
|
30
|
+
reportPath: testRunReportPath(docsRoot, runId),
|
|
31
|
+
agents: ['claude'],
|
|
32
|
+
model: 'claude-opus-4-8',
|
|
33
|
+
selectedFiles: ['guide.mdx'],
|
|
34
|
+
startedAt: '2026-09-01T12:00:00.000Z',
|
|
35
|
+
completedAt,
|
|
36
|
+
durationMs: 1_000,
|
|
37
|
+
tasks: [
|
|
38
|
+
{
|
|
39
|
+
id: 'claude:guide.mdx',
|
|
40
|
+
agent: 'claude',
|
|
41
|
+
file: 'guide.mdx',
|
|
42
|
+
directory: path.join(docsRoot, 'tests', 'mint-test', runId, 'claude', '001-guide'),
|
|
43
|
+
generatedFiles: [
|
|
44
|
+
path.join(
|
|
45
|
+
docsRoot,
|
|
46
|
+
'tests',
|
|
47
|
+
'mint-test',
|
|
48
|
+
runId,
|
|
49
|
+
'claude',
|
|
50
|
+
'001-guide',
|
|
51
|
+
'tests',
|
|
52
|
+
'guide.test.ts'
|
|
53
|
+
),
|
|
54
|
+
],
|
|
55
|
+
testable: true,
|
|
56
|
+
attempts: 1,
|
|
57
|
+
tokens: 100,
|
|
58
|
+
status: 'passed',
|
|
59
|
+
commands: [],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('stores each result and tracks every run in newest-first order', async () => {
|
|
66
|
+
const docsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-history-'));
|
|
67
|
+
temporaryDirectories.push(docsRoot);
|
|
68
|
+
const first = makeReport(docsRoot, '20260901120000-first', '2026-09-01T12:00:01.000Z');
|
|
69
|
+
const second = makeReport(docsRoot, '20260901130000-second', '2026-09-01T13:00:01.000Z');
|
|
70
|
+
|
|
71
|
+
await writeTestRunHistory(docsRoot, first);
|
|
72
|
+
await writeTestRunHistory(docsRoot, second);
|
|
73
|
+
|
|
74
|
+
expect(JSON.parse(await fs.readFile(testRunReportPath(docsRoot, first.runId), 'utf8'))).toEqual(
|
|
75
|
+
first
|
|
76
|
+
);
|
|
77
|
+
const index = await loadTestRuns(docsRoot);
|
|
78
|
+
expect(index.runs.map((run) => run.runId)).toEqual([second.runId, first.runId]);
|
|
79
|
+
expect(index.runs[0]?.counts).toEqual({
|
|
80
|
+
passed: 1,
|
|
81
|
+
failed: 0,
|
|
82
|
+
cancelled: 0,
|
|
83
|
+
agentErrors: 0,
|
|
84
|
+
});
|
|
85
|
+
await expect(loadLatestTestRun(docsRoot)).resolves.toEqual(second);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rebuilds testRuns.json data from saved run results', async () => {
|
|
89
|
+
const docsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-history-'));
|
|
90
|
+
temporaryDirectories.push(docsRoot);
|
|
91
|
+
const report = makeReport(docsRoot, '20260901120000-rebuild', '2026-09-01T12:00:01.000Z');
|
|
92
|
+
await writeTestRunHistory(docsRoot, report);
|
|
93
|
+
await fs.unlink(testRunsIndexPath(docsRoot));
|
|
94
|
+
|
|
95
|
+
const rebuilt = await loadTestRuns(docsRoot);
|
|
96
|
+
|
|
97
|
+
expect(rebuilt.runs).toHaveLength(1);
|
|
98
|
+
expect(rebuilt.runs[0]?.runId).toBe(report.runId);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps every run when history writes overlap', async () => {
|
|
102
|
+
const docsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-history-'));
|
|
103
|
+
temporaryDirectories.push(docsRoot);
|
|
104
|
+
const reports = Array.from({ length: 12 }, (_, index) =>
|
|
105
|
+
makeReport(
|
|
106
|
+
docsRoot,
|
|
107
|
+
`20260901120000-concurrent-${index}`,
|
|
108
|
+
`2026-09-01T12:00:${String(index).padStart(2, '0')}.000Z`
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await Promise.all(reports.map((report) => writeTestRunHistory(docsRoot, report)));
|
|
113
|
+
|
|
114
|
+
const index = JSON.parse(await fs.readFile(testRunsIndexPath(docsRoot), 'utf8')) as {
|
|
115
|
+
runs: Array<{ runId: string }>;
|
|
116
|
+
};
|
|
117
|
+
expect(index.runs.map((run) => run.runId).sort()).toEqual(
|
|
118
|
+
reports.map((report) => report.runId).sort()
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('recovers a stale history lock left by an interrupted process', async () => {
|
|
123
|
+
const docsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-history-'));
|
|
124
|
+
temporaryDirectories.push(docsRoot);
|
|
125
|
+
const historyDirectory = path.join(docsRoot, '.mintlify', 'test');
|
|
126
|
+
const lockPath = path.join(historyDirectory, 'testRuns.lock');
|
|
127
|
+
await fs.mkdir(historyDirectory, { recursive: true });
|
|
128
|
+
await fs.writeFile(lockPath, 'interrupted-process');
|
|
129
|
+
const staleTime = new Date(Date.now() - 31_000);
|
|
130
|
+
await fs.utimes(lockPath, staleTime, staleTime);
|
|
131
|
+
const report = makeReport(docsRoot, '20260901120000-after-crash', '2026-09-01T12:00:01.000Z');
|
|
132
|
+
|
|
133
|
+
await writeTestRunHistory(docsRoot, report);
|
|
134
|
+
|
|
135
|
+
await expect(fs.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
136
|
+
await expect(loadLatestTestRun(docsRoot)).resolves.toEqual(report);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns the report as-is when history is saved', async () => {
|
|
140
|
+
const docsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-history-'));
|
|
141
|
+
temporaryDirectories.push(docsRoot);
|
|
142
|
+
const report = makeReport(docsRoot, '20260901120000-saved', '2026-09-01T12:00:01.000Z');
|
|
143
|
+
const fallbackReportPath = path.join(report.outputDirectory, 'report.json');
|
|
144
|
+
|
|
145
|
+
await expect(recordTestRun(docsRoot, report, fallbackReportPath)).resolves.toBe(report);
|
|
146
|
+
await expect(loadLatestTestRun(docsRoot)).resolves.toEqual(report);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps a finished run usable when history cannot be saved', async () => {
|
|
150
|
+
const docsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-test-history-'));
|
|
151
|
+
temporaryDirectories.push(docsRoot);
|
|
152
|
+
await fs.mkdir(path.join(docsRoot, '.mintlify'), { recursive: true });
|
|
153
|
+
await fs.writeFile(path.join(docsRoot, '.mintlify', 'test'), 'blocks the history directory\n');
|
|
154
|
+
const report = makeReport(docsRoot, '20260901120000-unsaved', '2026-09-01T12:00:01.000Z');
|
|
155
|
+
const fallbackReportPath = path.join(report.outputDirectory, 'report.json');
|
|
156
|
+
|
|
157
|
+
const recorded = await recordTestRun(docsRoot, report, fallbackReportPath);
|
|
158
|
+
|
|
159
|
+
expect(recorded).toEqual({
|
|
160
|
+
...report,
|
|
161
|
+
reportPath: fallbackReportPath,
|
|
162
|
+
historyError: expect.stringContaining('run history could not be saved: '),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
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 { loadClaudeAgentSdk, loadCodexSdk } from './loadAgentSdk.js';
|
|
11
|
+
/**
|
|
12
|
+
* Fail fast, before any page is checked, when an agent SDK is not installed. Without this every
|
|
13
|
+
* page reports the same missing-dependency error minutes later as "test could not be generated".
|
|
14
|
+
*/
|
|
15
|
+
export function ensureAgentSdks(agents) {
|
|
16
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
17
|
+
for (const agent of new Set(agents)) {
|
|
18
|
+
if (agent === 'claude')
|
|
19
|
+
yield loadClaudeAgentSdk();
|
|
20
|
+
else
|
|
21
|
+
yield loadCodexSdk();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { SAFE_EXECUTABLES } from './manifest.js';
|
|
2
3
|
export const SYSTEM_PROMPT = `# Mint test generator
|
|
3
4
|
|
|
4
|
-
You will be
|
|
5
|
-
the code as
|
|
5
|
+
You will be given a markdown documentation page that contains code blocks. Follow the page and
|
|
6
|
+
implement the code as the page instructs, inside the given output directory.
|
|
6
7
|
|
|
7
8
|
Once finished, create a mint-test.json file in the current working directory with this shape:
|
|
8
9
|
|
|
@@ -12,12 +13,14 @@ Once finished, create a mint-test.json file in the current working directory wit
|
|
|
12
13
|
"setupCommands": [],
|
|
13
14
|
"testCommands": [
|
|
14
15
|
{
|
|
16
|
+
"id": "node-tests",
|
|
15
17
|
"executable": "node",
|
|
16
|
-
"args": ["--test"
|
|
18
|
+
"args": ["--test"],
|
|
17
19
|
"cwd": ".",
|
|
18
20
|
"timeoutMs": 120000
|
|
19
21
|
},
|
|
20
22
|
{
|
|
23
|
+
"id": "python-tests",
|
|
21
24
|
"executable": "python3",
|
|
22
25
|
"args": ["-m", "unittest", "discover", "-s", "tests"],
|
|
23
26
|
"cwd": ".",
|
|
@@ -26,11 +29,18 @@ Once finished, create a mint-test.json file in the current working directory wit
|
|
|
26
29
|
]
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
Manifest rules:
|
|
33
|
+
- "generatedFiles" lists every file you wrote, as paths relative to the current working directory. Each one must exist.
|
|
34
|
+
- Every command has a unique "id" (letters, digits, dashes). Command "cwd" values are relative to the current working directory.
|
|
35
|
+
- "executable" must be one of: ${[...SAFE_EXECUTABLES].join(', ')}, or a "./" script inside the current working directory. Do not use a shell, pipes, or "&&".
|
|
36
|
+
- Do not add any other keys to the manifest.
|
|
37
|
+
|
|
38
|
+
Finish with a short plain-text summary of what you tested and anything you could not test.
|
|
30
39
|
|
|
31
40
|
Rules:
|
|
32
41
|
- Read only the page named in the task. You may also read docs.json and an OpenAPI file referenced by that page.
|
|
33
|
-
- The output directory is already scaffolded: package.json exists with "npm test" wired to "node --test
|
|
42
|
+
- 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.
|
|
43
|
+
- 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.
|
|
34
44
|
- 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.
|
|
35
45
|
- Prefer the standard library over installing dependencies; add a setup command only when a test truly cannot run without one.
|
|
36
46
|
- Never write README, docs, or explanation files.
|
|
@@ -67,10 +77,10 @@ export const CHECK_JSON_INSTRUCTION = `
|
|
|
67
77
|
|
|
68
78
|
Respond with only this JSON object and nothing else:
|
|
69
79
|
{ "version": 1, "testable": false, "reason": "expected output, not runnable code" }`;
|
|
70
|
-
export function buildCheckPrompt(file,
|
|
80
|
+
export function buildCheckPrompt(file, fileContent) {
|
|
71
81
|
return `Report if the file at filePath ${JSON.stringify(file)} is testable
|
|
72
82
|
|
|
73
83
|
<file>
|
|
74
|
-
${
|
|
84
|
+
${fileContent}
|
|
75
85
|
</file>`;
|
|
76
86
|
}
|
|
@@ -48,6 +48,44 @@ function outputCollector() {
|
|
|
48
48
|
value: () => output,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
+
const MAX_FAILURE_LINE_LENGTH = 200;
|
|
52
|
+
const TAP_SUMMARY_PATTERN = /^#\s*(tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b|^1\.\.\d+$|^TAP version/;
|
|
53
|
+
const ERROR_LINE_PATTERN = /\b(?:[A-Za-z]*Error|Traceback|FAILED|ModuleNotFoundError)\b/;
|
|
54
|
+
const TAP_METADATA_PATTERN = /^(name|code|failureType|location|stack)\s*:/;
|
|
55
|
+
/**
|
|
56
|
+
* The most useful single line from a failed command's output: the first thrown error, else the
|
|
57
|
+
* first failing TAP assertion, else the last line that is not a TAP summary.
|
|
58
|
+
*/
|
|
59
|
+
export function pickFailureLine(text) {
|
|
60
|
+
var _a, _b, _c, _d, _e, _f;
|
|
61
|
+
const lines = text
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.map((raw) => raw.trim())
|
|
64
|
+
.filter((raw) => raw.length > 0 && !raw.startsWith('[mint test:'))
|
|
65
|
+
.map((raw) => ({ raw, text: raw.replace(/^#\s?/, '').trim() }))
|
|
66
|
+
.filter((line) => line.text.length > 0);
|
|
67
|
+
const thrown = lines.find(({ text: candidate }) => ERROR_LINE_PATTERN.test(candidate) &&
|
|
68
|
+
!candidate.startsWith('at ') &&
|
|
69
|
+
!/^(ok|Traceback)\b/.test(candidate) &&
|
|
70
|
+
!TAP_METADATA_PATTERN.test(candidate));
|
|
71
|
+
const assertion = lines.find(({ text: candidate }) => candidate.startsWith('not ok'));
|
|
72
|
+
const informative = lines.filter(({ raw }) => !TAP_SUMMARY_PATTERN.test(raw));
|
|
73
|
+
const line = (_f = (_d = (_b = (_a = thrown === null || thrown === void 0 ? void 0 : thrown.text) !== null && _a !== void 0 ? _a : assertion === null || assertion === void 0 ? void 0 : assertion.text) !== null && _b !== void 0 ? _b : (_c = informative[informative.length - 1]) === null || _c === void 0 ? void 0 : _c.text) !== null && _d !== void 0 ? _d : (_e = lines[lines.length - 1]) === null || _e === void 0 ? void 0 : _e.text) !== null && _f !== void 0 ? _f : '';
|
|
74
|
+
return line.length > MAX_FAILURE_LINE_LENGTH
|
|
75
|
+
? `${line.slice(0, MAX_FAILURE_LINE_LENGTH - 1)}…`
|
|
76
|
+
: line;
|
|
77
|
+
}
|
|
78
|
+
/** One line explaining the first command that did not pass, for the UI and the report. */
|
|
79
|
+
export function summarizeCommandFailure(commands) {
|
|
80
|
+
var _a;
|
|
81
|
+
const failed = commands.find((command) => command.status !== 'passed');
|
|
82
|
+
if (!failed)
|
|
83
|
+
return undefined;
|
|
84
|
+
const invocation = [failed.executable, ...failed.args].join(' ');
|
|
85
|
+
const reason = (_a = failed.error) !== null && _a !== void 0 ? _a : (failed.exitCode === null ? 'did not exit cleanly' : `exited with code ${failed.exitCode}`);
|
|
86
|
+
const detail = pickFailureLine(failed.stderr) || pickFailureLine(failed.stdout);
|
|
87
|
+
return `${failed.phase} command \`${invocation}\` ${reason}${detail ? `: ${detail}` : ''}`;
|
|
88
|
+
}
|
|
51
89
|
function unrunCommandResult(command, phase, cwd, status, error) {
|
|
52
90
|
return {
|
|
53
91
|
id: command.id,
|
|
@@ -137,12 +175,15 @@ function spawnCommand({ command, phase, cwd, timeoutMs, signal, docsRootForComma
|
|
|
137
175
|
export function executeCodeBlocks(_a) {
|
|
138
176
|
return __awaiter(this, arguments, void 0, function* ({ tasks, docsRoot, concurrency, commandTimeoutMs, signal, onTaskUpdate, }) {
|
|
139
177
|
return mapWithConcurrency(tasks, concurrency, (task) => __awaiter(this, void 0, void 0, function* () {
|
|
140
|
-
var _a;
|
|
178
|
+
var _a, _b, _c, _d;
|
|
179
|
+
const generatedFiles = (_c = (_b = (_a = task.manifest) === null || _a === void 0 ? void 0 : _a.generatedFiles.map((file) => path.resolve(task.directory, file))) !== null && _b !== void 0 ? _b : task.generatedFiles) !== null && _c !== void 0 ? _c : [];
|
|
141
180
|
const base = {
|
|
142
181
|
id: task.id,
|
|
143
182
|
agent: task.agent,
|
|
144
183
|
file: task.file,
|
|
145
184
|
directory: task.directory,
|
|
185
|
+
generatedFiles,
|
|
186
|
+
testable: task.testable === true,
|
|
146
187
|
attempts: task.attempts,
|
|
147
188
|
tokens: task.tokens,
|
|
148
189
|
};
|
|
@@ -153,6 +194,8 @@ export function executeCodeBlocks(_a) {
|
|
|
153
194
|
id: task.id,
|
|
154
195
|
agent: task.agent,
|
|
155
196
|
file: task.file,
|
|
197
|
+
directory: task.directory,
|
|
198
|
+
generatedFiles,
|
|
156
199
|
phase: 'not_testable',
|
|
157
200
|
tokens: task.tokens,
|
|
158
201
|
});
|
|
@@ -163,8 +206,11 @@ export function executeCodeBlocks(_a) {
|
|
|
163
206
|
id: task.id,
|
|
164
207
|
agent: task.agent,
|
|
165
208
|
file: task.file,
|
|
209
|
+
directory: task.directory,
|
|
210
|
+
generatedFiles,
|
|
166
211
|
phase: status,
|
|
167
212
|
tokens: task.tokens,
|
|
213
|
+
error: task.error,
|
|
168
214
|
});
|
|
169
215
|
return Object.assign(Object.assign({}, base), { status, error: task.error, commands: [] });
|
|
170
216
|
}
|
|
@@ -172,6 +218,8 @@ export function executeCodeBlocks(_a) {
|
|
|
172
218
|
id: task.id,
|
|
173
219
|
agent: task.agent,
|
|
174
220
|
file: task.file,
|
|
221
|
+
directory: task.directory,
|
|
222
|
+
generatedFiles,
|
|
175
223
|
phase: 'running',
|
|
176
224
|
tokens: task.tokens,
|
|
177
225
|
});
|
|
@@ -206,15 +254,10 @@ export function executeCodeBlocks(_a) {
|
|
|
206
254
|
for (const command of manifest.testCommands) {
|
|
207
255
|
commands.push(yield run(command, 'test'));
|
|
208
256
|
}
|
|
209
|
-
const status = (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
file: task.file,
|
|
214
|
-
phase: status,
|
|
215
|
-
tokens: task.tokens,
|
|
216
|
-
});
|
|
217
|
-
return Object.assign(Object.assign({}, base), { status, commands });
|
|
257
|
+
const status = (_d = FAILURE_PRECEDENCE.find((candidate) => commands.some((command) => command.status === candidate))) !== null && _d !== void 0 ? _d : 'passed';
|
|
258
|
+
const error = status === 'passed' ? undefined : summarizeCommandFailure(commands);
|
|
259
|
+
onTaskUpdate(Object.assign({ id: task.id, agent: task.agent, file: task.file, directory: task.directory, generatedFiles, phase: status, tokens: task.tokens }, (error ? { error } : {})));
|
|
260
|
+
return Object.assign(Object.assign(Object.assign(Object.assign({}, base), { status }), (error ? { error } : {})), { commands });
|
|
218
261
|
}));
|
|
219
262
|
});
|
|
220
263
|
}
|