@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,247 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import z from 'zod';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildCheckPrompt,
|
|
7
|
+
CHECK_JSON_INSTRUCTION,
|
|
8
|
+
CHECK_SYSTEM_PROMPT,
|
|
9
|
+
CHECK_TOOL_INSTRUCTION,
|
|
10
|
+
} from '../buildTaskPrompt.js';
|
|
11
|
+
import { loadClaudeAgentSdk, loadCodexSdk } from '../loadAgentSdk.js';
|
|
12
|
+
import {
|
|
13
|
+
GeneratedTask,
|
|
14
|
+
HarnessAgent,
|
|
15
|
+
RunCodeTestsOptions,
|
|
16
|
+
TaskUpdate,
|
|
17
|
+
TestCheck,
|
|
18
|
+
testCheckSchema,
|
|
19
|
+
} from '../types.js';
|
|
20
|
+
|
|
21
|
+
const CHECK_FILENAME = 'mint-test-check.json';
|
|
22
|
+
|
|
23
|
+
const CHECK_MODEL = 'claude-haiku-4-5-20251001';
|
|
24
|
+
|
|
25
|
+
async function readValidCheck(directory: string): Promise<TestCheck | null> {
|
|
26
|
+
try {
|
|
27
|
+
const check = testCheckSchema.parse(
|
|
28
|
+
JSON.parse(await fs.readFile(path.join(directory, CHECK_FILENAME), 'utf8'))
|
|
29
|
+
);
|
|
30
|
+
return validateCheck(check).length === 0 ? check : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateCheck(check: TestCheck): string[] {
|
|
37
|
+
return !check.testable && !check.reason ? ['Not-testable page needs a reason'] : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJsonReply(reply: string): unknown {
|
|
41
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(reply);
|
|
42
|
+
const raw = fenced?.[1] ?? reply;
|
|
43
|
+
const start = raw.indexOf('{');
|
|
44
|
+
const end = raw.lastIndexOf('}');
|
|
45
|
+
if (start === -1 || end === -1) throw new Error('Reply contained no JSON object');
|
|
46
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runTestabilityCheck({
|
|
50
|
+
file,
|
|
51
|
+
content,
|
|
52
|
+
agent,
|
|
53
|
+
outputPath,
|
|
54
|
+
signal,
|
|
55
|
+
}: {
|
|
56
|
+
file: string;
|
|
57
|
+
content: string;
|
|
58
|
+
agent: HarnessAgent;
|
|
59
|
+
outputPath: string;
|
|
60
|
+
signal: AbortSignal;
|
|
61
|
+
}): Promise<{ tokens: number; check: TestCheck }> {
|
|
62
|
+
const prompt = buildCheckPrompt(file, content);
|
|
63
|
+
const workingDirectory = path.dirname(outputPath);
|
|
64
|
+
let tokens = 0;
|
|
65
|
+
let raw: unknown;
|
|
66
|
+
if (agent === 'claude') {
|
|
67
|
+
const reply = await promptClaudeCheck({ prompt, workingDirectory, signal });
|
|
68
|
+
tokens = reply.tokens;
|
|
69
|
+
if (reply.captured === null) throw new Error('agent never called report_testability');
|
|
70
|
+
raw = reply.captured;
|
|
71
|
+
} else {
|
|
72
|
+
const reply = await promptCodex({
|
|
73
|
+
prompt,
|
|
74
|
+
systemPrompt: CHECK_SYSTEM_PROMPT + CHECK_JSON_INSTRUCTION,
|
|
75
|
+
workingDirectory,
|
|
76
|
+
signal,
|
|
77
|
+
});
|
|
78
|
+
tokens = reply.tokens;
|
|
79
|
+
raw = parseJsonReply(reply.result);
|
|
80
|
+
}
|
|
81
|
+
const check = testCheckSchema.parse(raw);
|
|
82
|
+
const errors = validateCheck(check);
|
|
83
|
+
if (errors.length > 0) throw new Error(errors.join('\n'));
|
|
84
|
+
await fs.writeFile(outputPath, `${JSON.stringify(check, null, 2)}\n`);
|
|
85
|
+
return { tokens, check };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const checkFileTestability = async (
|
|
89
|
+
task: GeneratedTask,
|
|
90
|
+
{ onTaskUpdate, signal }: Pick<RunCodeTestsOptions, 'onTaskUpdate' | 'signal'>
|
|
91
|
+
): Promise<GeneratedTask> => {
|
|
92
|
+
const emit = (phase: TaskUpdate['phase'], tokens: number) =>
|
|
93
|
+
onTaskUpdate({
|
|
94
|
+
id: task.id,
|
|
95
|
+
agent: task.agent,
|
|
96
|
+
file: task.file,
|
|
97
|
+
phase,
|
|
98
|
+
tokens,
|
|
99
|
+
});
|
|
100
|
+
let attempts = 0;
|
|
101
|
+
let tokens = 0;
|
|
102
|
+
let lastError = 'run cancelled';
|
|
103
|
+
|
|
104
|
+
let check = await readValidCheck(task.directory);
|
|
105
|
+
if (!check) {
|
|
106
|
+
emit('checking', tokens);
|
|
107
|
+
const content = await fs.readFile(path.join(task.docsRoot, task.file), 'utf8');
|
|
108
|
+
for (let attempt = 1; attempt <= 2 && !signal.aborted && !check; attempt++) {
|
|
109
|
+
attempts = attempt;
|
|
110
|
+
try {
|
|
111
|
+
const reply = await runTestabilityCheck({
|
|
112
|
+
file: task.file,
|
|
113
|
+
content,
|
|
114
|
+
agent: task.agent,
|
|
115
|
+
outputPath: path.join(task.directory, CHECK_FILENAME),
|
|
116
|
+
signal,
|
|
117
|
+
});
|
|
118
|
+
tokens += reply.tokens;
|
|
119
|
+
check = reply.check;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!check) {
|
|
125
|
+
if (signal.aborted) {
|
|
126
|
+
emit('cancelled', tokens);
|
|
127
|
+
return { ...task, attempts, tokens, error: 'run cancelled' };
|
|
128
|
+
}
|
|
129
|
+
emit('agent_error', tokens);
|
|
130
|
+
return { ...task, attempts, tokens, error: lastError };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!check.testable) {
|
|
135
|
+
emit('not_testable', tokens);
|
|
136
|
+
return { ...task, attempts, tokens, testable: false };
|
|
137
|
+
}
|
|
138
|
+
emit('checked', tokens);
|
|
139
|
+
return { ...task, attempts, tokens, testable: true };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
async function promptClaudeCheck({
|
|
143
|
+
prompt,
|
|
144
|
+
workingDirectory,
|
|
145
|
+
signal,
|
|
146
|
+
}: {
|
|
147
|
+
prompt: string;
|
|
148
|
+
workingDirectory: string;
|
|
149
|
+
signal: AbortSignal;
|
|
150
|
+
}): Promise<{ tokens: number; captured: unknown }> {
|
|
151
|
+
const { query, tool, createSdkMcpServer } = await loadClaudeAgentSdk();
|
|
152
|
+
const abortController = new AbortController();
|
|
153
|
+
const abort = () => abortController.abort(signal.reason);
|
|
154
|
+
if (signal.aborted) abort();
|
|
155
|
+
else signal.addEventListener('abort', abort, { once: true });
|
|
156
|
+
|
|
157
|
+
let captured: unknown = null;
|
|
158
|
+
const server = createSdkMcpServer({
|
|
159
|
+
name: 'mint-test',
|
|
160
|
+
tools: [
|
|
161
|
+
tool(
|
|
162
|
+
'report_testability',
|
|
163
|
+
'Report whether the documentation page is testable. Call exactly once.',
|
|
164
|
+
{
|
|
165
|
+
version: z.literal(1),
|
|
166
|
+
testable: z.boolean(),
|
|
167
|
+
reason: z.string().optional(),
|
|
168
|
+
},
|
|
169
|
+
async (input) => {
|
|
170
|
+
captured = input;
|
|
171
|
+
return { content: [{ type: 'text', text: 'recorded' }] };
|
|
172
|
+
}
|
|
173
|
+
),
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const session = query({
|
|
178
|
+
prompt,
|
|
179
|
+
options: {
|
|
180
|
+
abortController,
|
|
181
|
+
allowedTools: ['mcp__mint-test__report_testability'],
|
|
182
|
+
cwd: workingDirectory,
|
|
183
|
+
mcpServers: { 'mint-test': server },
|
|
184
|
+
model: CHECK_MODEL,
|
|
185
|
+
permissionMode: 'dontAsk',
|
|
186
|
+
persistSession: false,
|
|
187
|
+
settingSources: [],
|
|
188
|
+
strictMcpConfig: true,
|
|
189
|
+
systemPrompt: CHECK_SYSTEM_PROMPT + CHECK_TOOL_INSTRUCTION,
|
|
190
|
+
tools: [],
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
for await (const message of session) {
|
|
196
|
+
if (message.type !== 'result') continue;
|
|
197
|
+
if (message.subtype !== 'success' || message.is_error) {
|
|
198
|
+
const details = message.subtype === 'success' ? message.result : message.errors.join('; ');
|
|
199
|
+
throw new Error(details || `Claude stopped with ${message.subtype}`);
|
|
200
|
+
}
|
|
201
|
+
const usage = message.usage;
|
|
202
|
+
return {
|
|
203
|
+
tokens:
|
|
204
|
+
usage.input_tokens +
|
|
205
|
+
usage.output_tokens +
|
|
206
|
+
usage.cache_creation_input_tokens +
|
|
207
|
+
usage.cache_read_input_tokens,
|
|
208
|
+
captured,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
throw new Error('Claude finished without returning a result');
|
|
212
|
+
} finally {
|
|
213
|
+
signal.removeEventListener('abort', abort);
|
|
214
|
+
session.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function promptCodex({
|
|
219
|
+
prompt,
|
|
220
|
+
systemPrompt,
|
|
221
|
+
workingDirectory,
|
|
222
|
+
signal,
|
|
223
|
+
}: {
|
|
224
|
+
prompt: string;
|
|
225
|
+
systemPrompt: string;
|
|
226
|
+
workingDirectory: string;
|
|
227
|
+
signal: AbortSignal;
|
|
228
|
+
}): Promise<{ tokens: number; result: string }> {
|
|
229
|
+
const { Codex } = await loadCodexSdk();
|
|
230
|
+
const thread = new Codex().startThread({
|
|
231
|
+
approvalPolicy: 'never',
|
|
232
|
+
networkAccessEnabled: true,
|
|
233
|
+
sandboxMode: 'workspace-write',
|
|
234
|
+
skipGitRepoCheck: true,
|
|
235
|
+
workingDirectory,
|
|
236
|
+
});
|
|
237
|
+
const result = await thread.run(`${systemPrompt}\n\n${prompt}`, { signal });
|
|
238
|
+
const usage = result.usage;
|
|
239
|
+
const tokens = usage
|
|
240
|
+
? usage.input_tokens +
|
|
241
|
+
usage.cached_input_tokens +
|
|
242
|
+
usage.cache_write_input_tokens +
|
|
243
|
+
usage.output_tokens +
|
|
244
|
+
usage.reasoning_output_tokens
|
|
245
|
+
: 0;
|
|
246
|
+
return { tokens, result: result.finalResponse };
|
|
247
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export type HarnessAgent = 'claude' | 'codex';
|
|
4
|
+
|
|
5
|
+
export interface DocsPage {
|
|
6
|
+
file: string;
|
|
7
|
+
blockCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type CommandStatus = 'passed' | 'failed' | 'cancelled';
|
|
11
|
+
|
|
12
|
+
export interface CommandResult {
|
|
13
|
+
id: string;
|
|
14
|
+
phase: 'setup' | 'test';
|
|
15
|
+
status: CommandStatus;
|
|
16
|
+
executable: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
cwd: string;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TaskStatus = 'passed' | 'failed' | 'agent_error' | 'cancelled';
|
|
27
|
+
|
|
28
|
+
export interface TaskResult {
|
|
29
|
+
id: string;
|
|
30
|
+
agent: HarnessAgent;
|
|
31
|
+
file: string;
|
|
32
|
+
directory: string;
|
|
33
|
+
attempts: number;
|
|
34
|
+
tokens: number;
|
|
35
|
+
status: TaskStatus;
|
|
36
|
+
error?: string;
|
|
37
|
+
commands: CommandResult[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CodeTestReport {
|
|
41
|
+
version: 1;
|
|
42
|
+
runId: string;
|
|
43
|
+
status: 'passed' | 'completed' | 'failed' | 'cancelled';
|
|
44
|
+
docsRoot: string;
|
|
45
|
+
outputDirectory: string;
|
|
46
|
+
reportPath: string;
|
|
47
|
+
agents: HarnessAgent[];
|
|
48
|
+
selectedFiles: string[];
|
|
49
|
+
startedAt: string;
|
|
50
|
+
completedAt: string;
|
|
51
|
+
durationMs: number;
|
|
52
|
+
tasks: TaskResult[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type TaskPhase =
|
|
56
|
+
| 'checking'
|
|
57
|
+
| 'checked'
|
|
58
|
+
| 'not_testable'
|
|
59
|
+
| 'generating'
|
|
60
|
+
| 'generated'
|
|
61
|
+
| 'agent_error'
|
|
62
|
+
| 'running'
|
|
63
|
+
| 'passed'
|
|
64
|
+
| 'failed'
|
|
65
|
+
| 'cancelled';
|
|
66
|
+
|
|
67
|
+
export interface TaskUpdate {
|
|
68
|
+
id: string;
|
|
69
|
+
agent: HarnessAgent;
|
|
70
|
+
file: string;
|
|
71
|
+
phase: TaskPhase;
|
|
72
|
+
tokens: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RunSave {
|
|
76
|
+
version: 1;
|
|
77
|
+
runId: string;
|
|
78
|
+
agents: HarnessAgent[];
|
|
79
|
+
model: string;
|
|
80
|
+
selectedFiles: string[];
|
|
81
|
+
createdAt: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RunCodeTestsOptions {
|
|
85
|
+
path: string;
|
|
86
|
+
agents: HarnessAgent[];
|
|
87
|
+
model: string;
|
|
88
|
+
selectedFiles: string[];
|
|
89
|
+
outputDirectory: string;
|
|
90
|
+
runId: string | null;
|
|
91
|
+
concurrency: number;
|
|
92
|
+
commandTimeoutMs: number;
|
|
93
|
+
signal: AbortSignal;
|
|
94
|
+
onTaskUpdate: (update: TaskUpdate) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const identifier = z.string().trim().min(1).max(120);
|
|
98
|
+
|
|
99
|
+
const commandSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
id: identifier,
|
|
102
|
+
executable: z.string().trim().min(1).max(512),
|
|
103
|
+
args: z.array(z.string().max(10_000)).max(100),
|
|
104
|
+
cwd: z.string().max(1_024).optional(),
|
|
105
|
+
timeoutMs: z
|
|
106
|
+
.number()
|
|
107
|
+
.int()
|
|
108
|
+
.min(1_000)
|
|
109
|
+
.max(30 * 60 * 1_000)
|
|
110
|
+
.optional(),
|
|
111
|
+
})
|
|
112
|
+
.strict();
|
|
113
|
+
|
|
114
|
+
export const manifestSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
version: z.literal(1),
|
|
117
|
+
generatedFiles: z.array(z.string().min(1).max(1_024)).max(1_000),
|
|
118
|
+
setupCommands: z.array(commandSchema).max(50),
|
|
119
|
+
testCommands: z.array(commandSchema).max(200),
|
|
120
|
+
})
|
|
121
|
+
.strict();
|
|
122
|
+
|
|
123
|
+
export type GeneratedTestManifest = z.infer<typeof manifestSchema>;
|
|
124
|
+
|
|
125
|
+
export const testCheckSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
version: z.literal(1),
|
|
128
|
+
testable: z.boolean(),
|
|
129
|
+
reason: z
|
|
130
|
+
.string()
|
|
131
|
+
.max(2_000)
|
|
132
|
+
.optional()
|
|
133
|
+
.transform((value) => (value?.trim() ? value.trim() : undefined)),
|
|
134
|
+
})
|
|
135
|
+
.strict();
|
|
136
|
+
|
|
137
|
+
export type TestCheck = z.infer<typeof testCheckSchema>;
|
|
138
|
+
|
|
139
|
+
export type ManifestCommand = GeneratedTestManifest['testCommands'][number];
|
|
140
|
+
|
|
141
|
+
export interface GeneratedTask {
|
|
142
|
+
id: string;
|
|
143
|
+
agent: HarnessAgent;
|
|
144
|
+
docsRoot: string;
|
|
145
|
+
file: string;
|
|
146
|
+
directory: string;
|
|
147
|
+
attempts: number;
|
|
148
|
+
tokens: number;
|
|
149
|
+
testable?: boolean;
|
|
150
|
+
manifest?: GeneratedTestManifest;
|
|
151
|
+
error?: string;
|
|
152
|
+
}
|
package/src/cli.tsx
CHANGED
|
@@ -44,6 +44,7 @@ import { login } from './login.js';
|
|
|
44
44
|
import { logout } from './logout.js';
|
|
45
45
|
import { mdxLinter } from './mdxLinter.js';
|
|
46
46
|
import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
|
|
47
|
+
import { mintTestHandler } from './mintTest.js';
|
|
47
48
|
import { checkOpenApiFile, getOpenApiFilenamesFromDocsConfig } from './openApiCheck.js';
|
|
48
49
|
import { resolveExplicitFiles } from './resolveFiles.js';
|
|
49
50
|
import { scoreHandler } from './score/index.js';
|
|
@@ -85,15 +86,18 @@ const showWelcome = async (packageName: string): Promise<void> => {
|
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
88
|
-
|
|
89
|
+
const commandArguments = hideBin(process.argv);
|
|
90
|
+
if (shouldShowWelcome(commandArguments)) {
|
|
89
91
|
return showWelcome(packageName);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
const telemetryMiddleware = createTelemetryMiddleware();
|
|
93
|
-
|
|
95
|
+
if (commandArguments.find((argument) => !argument.startsWith('-')) !== 'test') {
|
|
96
|
+
render(<Logs />);
|
|
97
|
+
}
|
|
94
98
|
|
|
95
99
|
return (
|
|
96
|
-
yargs(
|
|
100
|
+
yargs(commandArguments)
|
|
97
101
|
.scriptName(packageName)
|
|
98
102
|
.option('telemetry', {
|
|
99
103
|
type: 'boolean',
|
|
@@ -239,6 +243,14 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
239
243
|
});
|
|
240
244
|
}
|
|
241
245
|
)
|
|
246
|
+
.command(
|
|
247
|
+
'test',
|
|
248
|
+
'Test runnable code blocks with a local coding agent',
|
|
249
|
+
(yargs) => yargs.usage('usage: mint test'),
|
|
250
|
+
async () => {
|
|
251
|
+
await mintTestHandler();
|
|
252
|
+
}
|
|
253
|
+
)
|
|
242
254
|
.command(
|
|
243
255
|
'export',
|
|
244
256
|
'Export a static site for air-gapped deployment',
|
|
@@ -762,12 +774,6 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
762
774
|
() => undefined,
|
|
763
775
|
comingSoon('ai', packageName)
|
|
764
776
|
)
|
|
765
|
-
.command(
|
|
766
|
-
'test',
|
|
767
|
-
'[Coming soon] Test your documentation (run mint test to vote)',
|
|
768
|
-
() => undefined,
|
|
769
|
-
comingSoon('test', packageName)
|
|
770
|
-
)
|
|
771
777
|
.command(
|
|
772
778
|
'mcp',
|
|
773
779
|
'[Coming soon] MCP server for documentation (run mint mcp to vote)',
|
package/src/mintTest.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
5
|
+
DEFAULT_CONCURRENCY,
|
|
6
|
+
clearRunSave,
|
|
7
|
+
discoverCodeBlocks,
|
|
8
|
+
loadRunSave,
|
|
9
|
+
runCodeTests,
|
|
10
|
+
type RunSave,
|
|
11
|
+
} from './agent-harness/index.js';
|
|
12
|
+
import { CMD_EXEC_PATH, findDocsRoot, isAI, terminate } from './helpers.js';
|
|
13
|
+
import { getAccessToken } from './keyring.js';
|
|
14
|
+
import { fileInNavPaths, loadDocsNavScope } from './mintTestNav.js';
|
|
15
|
+
import { countReport, formatDuration, runMintTestUi } from './mintTestUi.js';
|
|
16
|
+
import { getCliStatus } from './status.js';
|
|
17
|
+
|
|
18
|
+
export async function mintTestHandler(): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
const accessToken = await getAccessToken().catch(() => null);
|
|
21
|
+
if (!accessToken) {
|
|
22
|
+
process.stderr.write('not logged in. Run `mint login` to authenticate.\n');
|
|
23
|
+
await terminate(1);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const cliStatus = await getCliStatus(accessToken);
|
|
27
|
+
if (cliStatus?.org.name.toLowerCase() !== 'mintlify') {
|
|
28
|
+
process.stderr.write('mint test is not available for your organization.\n');
|
|
29
|
+
await terminate(1);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const targetPath = await findDocsRoot(CMD_EXEC_PATH);
|
|
33
|
+
const interactive =
|
|
34
|
+
process.stdin.isTTY === true &&
|
|
35
|
+
process.stdout.isTTY === true &&
|
|
36
|
+
!isAI() &&
|
|
37
|
+
!process.env.CI &&
|
|
38
|
+
process.env.CLI_TEST_MODE !== 'true';
|
|
39
|
+
|
|
40
|
+
const { docsRoot, pages: discoveredPages } = await discoverCodeBlocks(targetPath);
|
|
41
|
+
const scope = await loadDocsNavScope(docsRoot);
|
|
42
|
+
const pages = scope
|
|
43
|
+
? discoveredPages.filter((page) => fileInNavPaths(page.file, scope.navPaths))
|
|
44
|
+
: discoveredPages;
|
|
45
|
+
const autoSelectedFiles = (
|
|
46
|
+
scope ? pages.filter((page) => fileInNavPaths(page.file, scope.autoSelectedPaths)) : pages
|
|
47
|
+
).map((page) => page.file);
|
|
48
|
+
const totalPages = scope ? scope.navPaths.size : pages.length;
|
|
49
|
+
const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
|
|
50
|
+
|
|
51
|
+
let save: RunSave | null = await loadRunSave(outputDirectory);
|
|
52
|
+
if (save) {
|
|
53
|
+
const known = new Set(pages.map((page) => page.file));
|
|
54
|
+
const selectedFiles = save.selectedFiles.filter((file) => known.has(file));
|
|
55
|
+
if (selectedFiles.length === 0) {
|
|
56
|
+
await clearRunSave(outputDirectory);
|
|
57
|
+
save = null;
|
|
58
|
+
} else {
|
|
59
|
+
save = { ...save, selectedFiles };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (interactive) {
|
|
64
|
+
const exitCode = await runMintTestUi({
|
|
65
|
+
targetPath,
|
|
66
|
+
docsRoot,
|
|
67
|
+
pages,
|
|
68
|
+
autoSelectedFiles,
|
|
69
|
+
totalPages,
|
|
70
|
+
save,
|
|
71
|
+
});
|
|
72
|
+
await terminate(exitCode);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (save) process.stdout.write(`resuming run ${save.runId}\n`);
|
|
77
|
+
const report = await runCodeTests({
|
|
78
|
+
path: targetPath,
|
|
79
|
+
agents: save?.agents ?? ['claude'],
|
|
80
|
+
model: save?.model ?? 'claude-opus-4-8',
|
|
81
|
+
selectedFiles: save?.selectedFiles ?? autoSelectedFiles,
|
|
82
|
+
outputDirectory,
|
|
83
|
+
runId: save?.runId ?? null,
|
|
84
|
+
concurrency: DEFAULT_CONCURRENCY,
|
|
85
|
+
commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
|
|
86
|
+
signal: new AbortController().signal,
|
|
87
|
+
onTaskUpdate: () => {},
|
|
88
|
+
});
|
|
89
|
+
const counts = countReport(report.tasks);
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
`mint test ${report.status}: ${counts.passed} passed, ${counts.failed} failed, ${counts.agentErrors} agent errors\n`
|
|
92
|
+
);
|
|
93
|
+
process.stdout.write(`finished in ${formatDuration(report.durationMs)}\n`);
|
|
94
|
+
process.stdout.write(`report: ${report.reportPath}\n`);
|
|
95
|
+
await terminate(report.status === 'passed' ? 0 : 1);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`mint test failed: ${error instanceof Error ? error.message : 'unknown error'}\n`
|
|
99
|
+
);
|
|
100
|
+
await terminate(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getAllPathsInDocsNav } from '@mintlify/common';
|
|
2
|
+
import { getConfigObj } from '@mintlify/prebuild';
|
|
3
|
+
import type { DocsConfig, NavigationConfig } from '@mintlify/validation';
|
|
4
|
+
|
|
5
|
+
export interface DocsNavScope {
|
|
6
|
+
navPaths: Set<string>;
|
|
7
|
+
autoSelectedPaths: Set<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function loadDocsNavScope(docsRoot: string): Promise<DocsNavScope | null> {
|
|
11
|
+
const config = (await getConfigObj(docsRoot, 'docs').catch(() => null)) as DocsConfig | null;
|
|
12
|
+
const navigation = config?.navigation;
|
|
13
|
+
if (!navigation) return null;
|
|
14
|
+
const navPaths = new Set(getAllPathsInDocsNav(navigation));
|
|
15
|
+
if (navPaths.size === 0) return null;
|
|
16
|
+
const firstLanguage =
|
|
17
|
+
'languages' in navigation ? (navigation.languages[0] as NavigationConfig | undefined) : null;
|
|
18
|
+
const autoSelectedPaths = firstLanguage ? new Set(getAllPathsInDocsNav(firstLanguage)) : navPaths;
|
|
19
|
+
return { navPaths, autoSelectedPaths };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function navCandidatesForFile(file: string): string[] {
|
|
23
|
+
const base = file.replace(/\.mdx?$/i, '');
|
|
24
|
+
const candidates = [base];
|
|
25
|
+
if (base.endsWith('/index')) candidates.push(base.slice(0, -'/index'.length));
|
|
26
|
+
return candidates;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fileInNavPaths(file: string, paths: Set<string>): boolean {
|
|
30
|
+
return navCandidatesForFile(file).some((candidate) => paths.has(candidate));
|
|
31
|
+
}
|