@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,116 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { manifestSchema, type GeneratedTestManifest } from './types.js';
|
|
6
|
+
|
|
7
|
+
export const MANIFEST_FILENAME = 'mint-test.json';
|
|
8
|
+
export const AGENT_REPLY_FILENAME = 'mint-test-agent.md';
|
|
9
|
+
export const ERROR_FILENAME = 'mint-test-error.txt';
|
|
10
|
+
|
|
11
|
+
export const SAFE_EXECUTABLES = new Set([
|
|
12
|
+
'bun',
|
|
13
|
+
'bunx',
|
|
14
|
+
'bundle',
|
|
15
|
+
'cargo',
|
|
16
|
+
'composer',
|
|
17
|
+
'deno',
|
|
18
|
+
'dotnet',
|
|
19
|
+
'go',
|
|
20
|
+
'gradle',
|
|
21
|
+
'java',
|
|
22
|
+
'javac',
|
|
23
|
+
'mvn',
|
|
24
|
+
'node',
|
|
25
|
+
'npm',
|
|
26
|
+
'npx',
|
|
27
|
+
'php',
|
|
28
|
+
'pip',
|
|
29
|
+
'pip3',
|
|
30
|
+
'pnpm',
|
|
31
|
+
'python',
|
|
32
|
+
'python3',
|
|
33
|
+
'pytest',
|
|
34
|
+
'ruby',
|
|
35
|
+
'rustc',
|
|
36
|
+
'uv',
|
|
37
|
+
'vitest',
|
|
38
|
+
'yarn',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function isMissingFile(error: unknown): boolean {
|
|
42
|
+
return (
|
|
43
|
+
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn a schema failure into one readable line per problem, e.g. `testCommands.0.id: expected string`. */
|
|
48
|
+
export function formatManifestIssues(error: z.ZodError): string {
|
|
49
|
+
return error.issues
|
|
50
|
+
.map((issue) => {
|
|
51
|
+
const location = issue.path.map(String).join('.');
|
|
52
|
+
return `${location || MANIFEST_FILENAME}: ${issue.message}`;
|
|
53
|
+
})
|
|
54
|
+
.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read and schema-check the manifest an agent wrote. Every failure mode has a message a
|
|
59
|
+
* documentation author can act on, because it is what `mint test` shows for the page.
|
|
60
|
+
*/
|
|
61
|
+
export async function readManifest(directory: string): Promise<GeneratedTestManifest> {
|
|
62
|
+
const manifestPath = path.join(directory, MANIFEST_FILENAME);
|
|
63
|
+
let raw: string;
|
|
64
|
+
try {
|
|
65
|
+
raw = await fs.readFile(manifestPath, 'utf8');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (isMissingFile(error)) {
|
|
68
|
+
throw new Error(`The agent finished without writing ${MANIFEST_FILENAME}`);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(raw);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
78
|
+
throw new Error(`${MANIFEST_FILENAME} is not valid JSON: ${reason}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = manifestSchema.safeParse(parsed);
|
|
82
|
+
if (!result.success) {
|
|
83
|
+
const [first = '', ...rest] = formatManifestIssues(result.error).split('\n');
|
|
84
|
+
throw new Error(
|
|
85
|
+
[`${MANIFEST_FILENAME} does not match the expected shape: ${first}`, ...rest].join('\n')
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return result.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Keep the agent's final message next to its output so a failure can be traced later. */
|
|
92
|
+
export async function appendAgentReply(
|
|
93
|
+
directory: string,
|
|
94
|
+
attempt: number,
|
|
95
|
+
reply: string
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const body = reply.trim() || '(no summary)';
|
|
98
|
+
await fs
|
|
99
|
+
.appendFile(path.join(directory, AGENT_REPLY_FILENAME), `## Attempt ${attempt}\n\n${body}\n\n`)
|
|
100
|
+
.catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Persist every attempt's failure reason. Returns the file path, or undefined if it could not be written. */
|
|
104
|
+
export async function writeErrorDetails(
|
|
105
|
+
directory: string,
|
|
106
|
+
errors: string[]
|
|
107
|
+
): Promise<string | undefined> {
|
|
108
|
+
if (errors.length === 0) return undefined;
|
|
109
|
+
const errorPath = path.join(directory, ERROR_FILENAME);
|
|
110
|
+
try {
|
|
111
|
+
await fs.writeFile(errorPath, `${errors.join('\n\n')}\n`);
|
|
112
|
+
return errorPath;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { countReport } from './countReport.js';
|
|
8
|
+
import type { CodeTestReport, TestRunSummary, TestRunsIndex } from './types.js';
|
|
9
|
+
|
|
10
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
11
|
+
const INDEX_LOCK_FILENAME = 'testRuns.lock';
|
|
12
|
+
const LOCK_RETRY_MS = 20;
|
|
13
|
+
const STALE_LOCK_MS = 30_000;
|
|
14
|
+
const LOCK_TIMEOUT_MS = STALE_LOCK_MS + 10_000;
|
|
15
|
+
const commandResultSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
phase: z.enum(['setup', 'test']),
|
|
19
|
+
status: z.enum(['passed', 'failed', 'cancelled']),
|
|
20
|
+
executable: z.string(),
|
|
21
|
+
args: z.array(z.string()),
|
|
22
|
+
cwd: z.string(),
|
|
23
|
+
durationMs: z.number().nonnegative(),
|
|
24
|
+
exitCode: z.number().int().nullable(),
|
|
25
|
+
stdout: z.string(),
|
|
26
|
+
stderr: z.string(),
|
|
27
|
+
error: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
const taskResultSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
agent: z.enum(['claude', 'codex']),
|
|
34
|
+
file: z.string(),
|
|
35
|
+
directory: z.string(),
|
|
36
|
+
generatedFiles: z.array(z.string()),
|
|
37
|
+
testable: z.boolean(),
|
|
38
|
+
attempts: z.number().int().nonnegative(),
|
|
39
|
+
tokens: z.number().nonnegative(),
|
|
40
|
+
status: z.enum(['passed', 'failed', 'agent_error', 'cancelled']),
|
|
41
|
+
error: z.string().optional(),
|
|
42
|
+
commands: z.array(commandResultSchema),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
const codeTestReportSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
version: z.literal(1),
|
|
48
|
+
runId: z.string().regex(RUN_ID_PATTERN),
|
|
49
|
+
status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
|
|
50
|
+
docsRoot: z.string(),
|
|
51
|
+
outputDirectory: z.string(),
|
|
52
|
+
reportPath: z.string(),
|
|
53
|
+
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
54
|
+
model: z.string().min(1),
|
|
55
|
+
selectedFiles: z.array(z.string()),
|
|
56
|
+
startedAt: z.string(),
|
|
57
|
+
completedAt: z.string(),
|
|
58
|
+
durationMs: z.number().nonnegative(),
|
|
59
|
+
tasks: z.array(taskResultSchema),
|
|
60
|
+
historyError: z.string().optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
const countsSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
passed: z.number().int().nonnegative(),
|
|
66
|
+
failed: z.number().int().nonnegative(),
|
|
67
|
+
cancelled: z.number().int().nonnegative(),
|
|
68
|
+
agentErrors: z.number().int().nonnegative(),
|
|
69
|
+
})
|
|
70
|
+
.strict();
|
|
71
|
+
const testRunSummarySchema = z
|
|
72
|
+
.object({
|
|
73
|
+
runId: z.string().regex(RUN_ID_PATTERN),
|
|
74
|
+
status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
|
|
75
|
+
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
76
|
+
model: z.string().min(1),
|
|
77
|
+
selectedFiles: z.array(z.string()),
|
|
78
|
+
startedAt: z.string(),
|
|
79
|
+
completedAt: z.string(),
|
|
80
|
+
durationMs: z.number().nonnegative(),
|
|
81
|
+
reportPath: z.string(),
|
|
82
|
+
outputDirectory: z.string(),
|
|
83
|
+
counts: countsSchema,
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
const testRunsIndexSchema = z
|
|
87
|
+
.object({ version: z.literal(1), runs: z.array(testRunSummarySchema) })
|
|
88
|
+
.strict();
|
|
89
|
+
|
|
90
|
+
function historyDirectory(docsRoot: string): string {
|
|
91
|
+
return path.join(docsRoot, '.mintlify', 'test');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function runsDirectory(docsRoot: string): string {
|
|
95
|
+
return path.join(historyDirectory(docsRoot), 'runs');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function testRunReportPath(docsRoot: string, runId: string): string {
|
|
99
|
+
if (!RUN_ID_PATTERN.test(runId)) throw new Error(`Invalid mint test run ID: ${runId}`);
|
|
100
|
+
return path.join(runsDirectory(docsRoot), `${runId}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function testRunsIndexPath(docsRoot: string): string {
|
|
104
|
+
return path.join(historyDirectory(docsRoot), 'testRuns.json');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarizeReport(report: CodeTestReport): TestRunSummary {
|
|
108
|
+
return {
|
|
109
|
+
runId: report.runId,
|
|
110
|
+
status: report.status,
|
|
111
|
+
agents: report.agents,
|
|
112
|
+
model: report.model,
|
|
113
|
+
selectedFiles: report.selectedFiles,
|
|
114
|
+
startedAt: report.startedAt,
|
|
115
|
+
completedAt: report.completedAt,
|
|
116
|
+
durationMs: report.durationMs,
|
|
117
|
+
reportPath: report.reportPath,
|
|
118
|
+
outputDirectory: report.outputDirectory,
|
|
119
|
+
counts: countReport(report.tasks),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function errorCode(error: unknown): string | undefined {
|
|
124
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
125
|
+
? String(error.code)
|
|
126
|
+
: undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
|
|
130
|
+
const temporaryPath = path.join(
|
|
131
|
+
path.dirname(filePath),
|
|
132
|
+
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
133
|
+
);
|
|
134
|
+
try {
|
|
135
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
136
|
+
await fs.rename(temporaryPath, filePath);
|
|
137
|
+
} finally {
|
|
138
|
+
await fs.unlink(temporaryPath).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function acquireIndexLock(docsRoot: string): Promise<() => Promise<void>> {
|
|
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
|
+
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
try {
|
|
149
|
+
const lock = await fs.open(lockPath, 'wx');
|
|
150
|
+
try {
|
|
151
|
+
await lock.writeFile(token);
|
|
152
|
+
} finally {
|
|
153
|
+
await lock.close();
|
|
154
|
+
}
|
|
155
|
+
return async () => {
|
|
156
|
+
const owner = await fs.readFile(lockPath, 'utf8').catch(() => undefined);
|
|
157
|
+
if (owner === token) await fs.unlink(lockPath).catch(() => undefined);
|
|
158
|
+
};
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (errorCode(error) !== 'EEXIST') throw error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const stats = await fs.stat(lockPath).catch(() => undefined);
|
|
164
|
+
if (stats && Date.now() - stats.mtimeMs > STALE_LOCK_MS) {
|
|
165
|
+
await fs.unlink(lockPath).catch(() => undefined);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
await delay(LOCK_RETRY_MS);
|
|
169
|
+
}
|
|
170
|
+
throw new Error('Timed out updating mint test run history');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readReport(filePath: string): Promise<CodeTestReport | null> {
|
|
174
|
+
try {
|
|
175
|
+
return codeTestReportSchema.parse(JSON.parse(await fs.readFile(filePath, 'utf8')));
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function rebuildIndex(docsRoot: string): Promise<TestRunsIndex> {
|
|
182
|
+
const entries = await fs
|
|
183
|
+
.readdir(runsDirectory(docsRoot), { withFileTypes: true })
|
|
184
|
+
.catch(() => []);
|
|
185
|
+
const reports = await Promise.all(
|
|
186
|
+
entries
|
|
187
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
188
|
+
.map((entry) => readReport(path.join(runsDirectory(docsRoot), entry.name)))
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
version: 1,
|
|
192
|
+
runs: reports
|
|
193
|
+
.filter((report): report is CodeTestReport => report !== null)
|
|
194
|
+
.map(summarizeReport)
|
|
195
|
+
.sort((left, right) => right.completedAt.localeCompare(left.completedAt)),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function loadTestRuns(docsRoot: string): Promise<TestRunsIndex> {
|
|
200
|
+
try {
|
|
201
|
+
return testRunsIndexSchema.parse(
|
|
202
|
+
JSON.parse(await fs.readFile(testRunsIndexPath(docsRoot), 'utf8'))
|
|
203
|
+
);
|
|
204
|
+
} catch {
|
|
205
|
+
return rebuildIndex(docsRoot);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function loadLatestTestRun(docsRoot: string): Promise<CodeTestReport | null> {
|
|
210
|
+
const index = await loadTestRuns(docsRoot);
|
|
211
|
+
for (const run of index.runs) {
|
|
212
|
+
const report = await readReport(testRunReportPath(docsRoot, run.runId));
|
|
213
|
+
if (report) return report;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function writeTestRunHistory(docsRoot: string, report: CodeTestReport): Promise<void> {
|
|
219
|
+
await fs.mkdir(runsDirectory(docsRoot), { recursive: true });
|
|
220
|
+
await writeJsonAtomically(report.reportPath, report);
|
|
221
|
+
const releaseLock = await acquireIndexLock(docsRoot);
|
|
222
|
+
try {
|
|
223
|
+
await writeJsonAtomically(testRunsIndexPath(docsRoot), await rebuildIndex(docsRoot));
|
|
224
|
+
} finally {
|
|
225
|
+
await releaseLock();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Saves a finished run to history without letting a history failure fail the run.
|
|
231
|
+
* On failure the returned report points at `fallbackReportPath` (the report that was
|
|
232
|
+
* already written) and carries the problem in `historyError`.
|
|
233
|
+
*/
|
|
234
|
+
export async function recordTestRun(
|
|
235
|
+
docsRoot: string,
|
|
236
|
+
report: CodeTestReport,
|
|
237
|
+
fallbackReportPath: string
|
|
238
|
+
): Promise<CodeTestReport> {
|
|
239
|
+
try {
|
|
240
|
+
await writeTestRunHistory(docsRoot, report);
|
|
241
|
+
return report;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
return {
|
|
245
|
+
...report,
|
|
246
|
+
reportPath: fallbackReportPath,
|
|
247
|
+
historyError: `run history could not be saved: ${message}`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -9,17 +9,32 @@ const PACKAGE_JSON = `${JSON.stringify(
|
|
|
9
9
|
name: 'mint-test-task',
|
|
10
10
|
private: true,
|
|
11
11
|
type: 'module',
|
|
12
|
-
|
|
12
|
+
// No positional path: Node 21+ treats --test arguments as globs, so a bare
|
|
13
|
+
// directory such as `tests/` fails with MODULE_NOT_FOUND. The default patterns
|
|
14
|
+
// pick up tests/*.test.js on every supported Node version.
|
|
15
|
+
scripts: { test: 'node --test' },
|
|
13
16
|
},
|
|
14
17
|
null,
|
|
15
18
|
2
|
|
16
19
|
)}\n`;
|
|
17
20
|
|
|
21
|
+
function isExistingFile(error: unknown): boolean {
|
|
22
|
+
return (
|
|
23
|
+
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'EEXIST'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
async function scaffoldTaskDirectory(directory: string): Promise<void> {
|
|
19
28
|
await fs.mkdir(path.join(directory, 'tests'), { recursive: true });
|
|
20
|
-
|
|
21
|
-
.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' })
|
|
22
|
-
|
|
29
|
+
try {
|
|
30
|
+
await fs.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (!isExistingFile(error)) throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function scaffoldTaskDirectories(tasks: GeneratedTask[]): Promise<void> {
|
|
37
|
+
for (const task of tasks) await scaffoldTaskDirectory(task.directory);
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
export async function setupFolders({
|
|
@@ -68,6 +83,5 @@ export async function setupFolders({
|
|
|
68
83
|
});
|
|
69
84
|
}
|
|
70
85
|
}
|
|
71
|
-
for (const task of tasks) await scaffoldTaskDirectory(task.directory);
|
|
72
86
|
return { runId, runDirectory, reportPath, tasks };
|
|
73
87
|
}
|
|
@@ -50,17 +50,16 @@ async function runTestabilityCheck({
|
|
|
50
50
|
file,
|
|
51
51
|
content,
|
|
52
52
|
agent,
|
|
53
|
-
|
|
53
|
+
workingDirectory,
|
|
54
54
|
signal,
|
|
55
55
|
}: {
|
|
56
56
|
file: string;
|
|
57
57
|
content: string;
|
|
58
58
|
agent: HarnessAgent;
|
|
59
|
-
|
|
59
|
+
workingDirectory: string;
|
|
60
60
|
signal: AbortSignal;
|
|
61
61
|
}): Promise<{ tokens: number; check: TestCheck }> {
|
|
62
62
|
const prompt = buildCheckPrompt(file, content);
|
|
63
|
-
const workingDirectory = path.dirname(outputPath);
|
|
64
63
|
let tokens = 0;
|
|
65
64
|
let raw: unknown;
|
|
66
65
|
if (agent === 'claude') {
|
|
@@ -81,19 +80,24 @@ async function runTestabilityCheck({
|
|
|
81
80
|
const check = testCheckSchema.parse(raw);
|
|
82
81
|
const errors = validateCheck(check);
|
|
83
82
|
if (errors.length > 0) throw new Error(errors.join('\n'));
|
|
84
|
-
await fs.writeFile(outputPath, `${JSON.stringify(check, null, 2)}\n`);
|
|
85
83
|
return { tokens, check };
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
export const checkFileTestability = async (
|
|
89
87
|
task: GeneratedTask,
|
|
90
|
-
{
|
|
88
|
+
{
|
|
89
|
+
onTaskUpdate,
|
|
90
|
+
signal,
|
|
91
|
+
runDirectory,
|
|
92
|
+
}: Pick<RunCodeTestsOptions, 'onTaskUpdate' | 'signal'> & { runDirectory: string }
|
|
91
93
|
): Promise<GeneratedTask> => {
|
|
92
94
|
const emit = (phase: TaskUpdate['phase'], tokens: number) =>
|
|
93
95
|
onTaskUpdate({
|
|
94
96
|
id: task.id,
|
|
95
97
|
agent: task.agent,
|
|
96
98
|
file: task.file,
|
|
99
|
+
directory: task.directory,
|
|
100
|
+
generatedFiles: [],
|
|
97
101
|
phase,
|
|
98
102
|
tokens,
|
|
99
103
|
});
|
|
@@ -112,7 +116,7 @@ export const checkFileTestability = async (
|
|
|
112
116
|
file: task.file,
|
|
113
117
|
content,
|
|
114
118
|
agent: task.agent,
|
|
115
|
-
|
|
119
|
+
workingDirectory: runDirectory,
|
|
116
120
|
signal,
|
|
117
121
|
});
|
|
118
122
|
tokens += reply.tokens;
|
|
@@ -135,6 +139,11 @@ export const checkFileTestability = async (
|
|
|
135
139
|
emit('not_testable', tokens);
|
|
136
140
|
return { ...task, attempts, tokens, testable: false };
|
|
137
141
|
}
|
|
142
|
+
await fs.mkdir(task.directory, { recursive: true });
|
|
143
|
+
await fs.writeFile(
|
|
144
|
+
path.join(task.directory, CHECK_FILENAME),
|
|
145
|
+
`${JSON.stringify(check, null, 2)}\n`
|
|
146
|
+
);
|
|
138
147
|
emit('checked', tokens);
|
|
139
148
|
return { ...task, attempts, tokens, testable: true };
|
|
140
149
|
};
|
|
@@ -30,6 +30,8 @@ export interface TaskResult {
|
|
|
30
30
|
agent: HarnessAgent;
|
|
31
31
|
file: string;
|
|
32
32
|
directory: string;
|
|
33
|
+
generatedFiles: string[];
|
|
34
|
+
testable: boolean;
|
|
33
35
|
attempts: number;
|
|
34
36
|
tokens: number;
|
|
35
37
|
status: TaskStatus;
|
|
@@ -45,11 +47,38 @@ export interface CodeTestReport {
|
|
|
45
47
|
outputDirectory: string;
|
|
46
48
|
reportPath: string;
|
|
47
49
|
agents: HarnessAgent[];
|
|
50
|
+
model: string;
|
|
48
51
|
selectedFiles: string[];
|
|
49
52
|
startedAt: string;
|
|
50
53
|
completedAt: string;
|
|
51
54
|
durationMs: number;
|
|
52
55
|
tasks: TaskResult[];
|
|
56
|
+
/** Set when the run finished but could not be saved to `.mintlify/test` history. */
|
|
57
|
+
historyError?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TestRunSummary {
|
|
61
|
+
runId: string;
|
|
62
|
+
status: CodeTestReport['status'];
|
|
63
|
+
agents: HarnessAgent[];
|
|
64
|
+
model: string;
|
|
65
|
+
selectedFiles: string[];
|
|
66
|
+
startedAt: string;
|
|
67
|
+
completedAt: string;
|
|
68
|
+
durationMs: number;
|
|
69
|
+
reportPath: string;
|
|
70
|
+
outputDirectory: string;
|
|
71
|
+
counts: {
|
|
72
|
+
passed: number;
|
|
73
|
+
failed: number;
|
|
74
|
+
cancelled: number;
|
|
75
|
+
agentErrors: number;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface TestRunsIndex {
|
|
80
|
+
version: 1;
|
|
81
|
+
runs: TestRunSummary[];
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
export type TaskPhase =
|
|
@@ -68,8 +97,12 @@ export interface TaskUpdate {
|
|
|
68
97
|
id: string;
|
|
69
98
|
agent: HarnessAgent;
|
|
70
99
|
file: string;
|
|
100
|
+
directory: string;
|
|
101
|
+
generatedFiles: string[];
|
|
71
102
|
phase: TaskPhase;
|
|
72
103
|
tokens: number;
|
|
104
|
+
/** Why the task ended in `agent_error` or `failed`; the first line is shown in the UI. */
|
|
105
|
+
error?: string;
|
|
73
106
|
}
|
|
74
107
|
|
|
75
108
|
export interface RunSave {
|
|
@@ -98,7 +131,7 @@ const identifier = z.string().trim().min(1).max(120);
|
|
|
98
131
|
|
|
99
132
|
const commandSchema = z
|
|
100
133
|
.object({
|
|
101
|
-
id: identifier,
|
|
134
|
+
id: identifier.optional(),
|
|
102
135
|
executable: z.string().trim().min(1).max(512),
|
|
103
136
|
args: z.array(z.string().max(10_000)).max(100),
|
|
104
137
|
cwd: z.string().max(1_024).optional(),
|
|
@@ -111,7 +144,7 @@ const commandSchema = z
|
|
|
111
144
|
})
|
|
112
145
|
.strict();
|
|
113
146
|
|
|
114
|
-
|
|
147
|
+
const manifestFileSchema = z
|
|
115
148
|
.object({
|
|
116
149
|
version: z.literal(1),
|
|
117
150
|
generatedFiles: z.array(z.string().min(1).max(1_024)).max(1_000),
|
|
@@ -120,7 +153,51 @@ export const manifestSchema = z
|
|
|
120
153
|
})
|
|
121
154
|
.strict();
|
|
122
155
|
|
|
123
|
-
|
|
156
|
+
type ManifestFile = z.infer<typeof manifestFileSchema>;
|
|
157
|
+
type ManifestFileCommand = ManifestFile['testCommands'][number];
|
|
158
|
+
|
|
159
|
+
export type ManifestCommand = Omit<ManifestFileCommand, 'id'> & { id: string };
|
|
160
|
+
|
|
161
|
+
export interface GeneratedTestManifest {
|
|
162
|
+
version: 1;
|
|
163
|
+
generatedFiles: string[];
|
|
164
|
+
setupCommands: ManifestCommand[];
|
|
165
|
+
testCommands: ManifestCommand[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Agents often copy the manifest example verbatim and omit command ids. The id only needs to be
|
|
170
|
+
* unique within the manifest, so assign `setup-N` / `test-N` instead of rejecting the whole page.
|
|
171
|
+
*/
|
|
172
|
+
export function assignCommandIds(manifest: ManifestFile): GeneratedTestManifest {
|
|
173
|
+
const taken = new Set(
|
|
174
|
+
[...manifest.setupCommands, ...manifest.testCommands]
|
|
175
|
+
.map((command) => command.id)
|
|
176
|
+
.filter((id): id is string => id !== undefined)
|
|
177
|
+
);
|
|
178
|
+
const withIds = (commands: ManifestFileCommand[], phase: 'setup' | 'test'): ManifestCommand[] => {
|
|
179
|
+
let next = 1;
|
|
180
|
+
return commands.map((command) => {
|
|
181
|
+
if (command.id !== undefined) return { ...command, id: command.id };
|
|
182
|
+
let id = `${phase}-${next}`;
|
|
183
|
+
while (taken.has(id)) {
|
|
184
|
+
next += 1;
|
|
185
|
+
id = `${phase}-${next}`;
|
|
186
|
+
}
|
|
187
|
+
taken.add(id);
|
|
188
|
+
next += 1;
|
|
189
|
+
return { ...command, id };
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
version: manifest.version,
|
|
194
|
+
generatedFiles: manifest.generatedFiles,
|
|
195
|
+
setupCommands: withIds(manifest.setupCommands, 'setup'),
|
|
196
|
+
testCommands: withIds(manifest.testCommands, 'test'),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const manifestSchema = manifestFileSchema.transform(assignCommandIds);
|
|
124
201
|
|
|
125
202
|
export const testCheckSchema = z
|
|
126
203
|
.object({
|
|
@@ -136,8 +213,6 @@ export const testCheckSchema = z
|
|
|
136
213
|
|
|
137
214
|
export type TestCheck = z.infer<typeof testCheckSchema>;
|
|
138
215
|
|
|
139
|
-
export type ManifestCommand = GeneratedTestManifest['testCommands'][number];
|
|
140
|
-
|
|
141
216
|
export interface GeneratedTask {
|
|
142
217
|
id: string;
|
|
143
218
|
agent: HarnessAgent;
|
|
@@ -148,5 +223,6 @@ export interface GeneratedTask {
|
|
|
148
223
|
tokens: number;
|
|
149
224
|
testable?: boolean;
|
|
150
225
|
manifest?: GeneratedTestManifest;
|
|
226
|
+
generatedFiles?: string[];
|
|
151
227
|
error?: string;
|
|
152
228
|
}
|
package/src/constants.ts
CHANGED
|
@@ -29,7 +29,11 @@ const PROD_TOKEN_ENDPOINT =
|
|
|
29
29
|
'https://api.stytch.com/v1/public/project-live-731b7a04-9ac3-4923-90b8-0806d4aa29d4/oauth2/token';
|
|
30
30
|
const PROD_STYTCH_CLIENT_ID = 'connected-app-live-d813eedd-dbb0-434b-a1f9-2ce69e5efc49';
|
|
31
31
|
|
|
32
|
-
export const TOKEN_ENDPOINT =
|
|
33
|
-
|
|
32
|
+
export const TOKEN_ENDPOINT =
|
|
33
|
+
process.env.MINTLIFY_TOKEN_ENDPOINT ??
|
|
34
|
+
(IS_LOCAL_BUILD ? DEV_TOKEN_ENDPOINT : PROD_TOKEN_ENDPOINT);
|
|
35
|
+
export const STYTCH_CLIENT_ID =
|
|
36
|
+
process.env.MINTLIFY_STYTCH_CLIENT_ID ??
|
|
37
|
+
(IS_LOCAL_BUILD ? DEV_STYTCH_CLIENT_ID : PROD_STYTCH_CLIENT_ID);
|
|
34
38
|
|
|
35
39
|
export const CUSTOM_DOMAIN_CNAME_TARGET = 'cname.mintlify.builders';
|