@mintlify/cli 4.0.1437 → 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/mintTest.js +1 -1
- package/bin/mintTestUi.js +1 -1
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +6 -3
- 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/mintTest.tsx +3 -3
- package/src/mintTestTree.ts +1 -1
- package/src/mintTestUi.tsx +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mintlify/cli",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1438",
|
|
4
4
|
"description": "The Mintlify CLI",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@inquirer/prompts": "7.9.0",
|
|
48
|
-
"@mintlify/agent-harness": "0.0.3",
|
|
49
48
|
"@mintlify/common": "1.0.1111",
|
|
50
49
|
"@mintlify/link-rot": "3.0.1313",
|
|
51
50
|
"@mintlify/models": "0.0.351",
|
|
@@ -80,6 +79,10 @@
|
|
|
80
79
|
"zod": "4.3.6"
|
|
81
80
|
},
|
|
82
81
|
"optionalDependencies": {
|
|
82
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.241",
|
|
83
|
+
"@anthropic-ai/sdk": "0.120.0",
|
|
84
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
85
|
+
"@openai/codex-sdk": "0.149.1",
|
|
83
86
|
"keytar": "7.9.0"
|
|
84
87
|
},
|
|
85
88
|
"devDependencies": {
|
|
@@ -102,5 +105,5 @@
|
|
|
102
105
|
"vitest": "2.1.9",
|
|
103
106
|
"vitest-mock-process": "1.0.4"
|
|
104
107
|
},
|
|
105
|
-
"gitHead": "
|
|
108
|
+
"gitHead": "a7aa8658185853553564a04b1132bbd9dec768d5"
|
|
106
109
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# `mint test` system design
|
|
2
|
+
|
|
3
|
+
`packages/cli` owns command parsing and terminal interaction. `packages/cli/src/agent-harness` collects code blocks, runs agents, validates generated projects, executes commands, and writes reports so other callers can use the same test engine without the CLI.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
flowchart TD
|
|
7
|
+
User[Developer] --> Command["mint test [path]"]
|
|
8
|
+
|
|
9
|
+
subgraph CLI["packages/cli"]
|
|
10
|
+
Command --> Args["Parse path, agent, scope, and TUI flags"]
|
|
11
|
+
Mode{"Interactive TTY?"}
|
|
12
|
+
UI["Ink flow: agent, code blocks, scope, progress, results"]
|
|
13
|
+
Plain["Non-interactive runner with Claude default"]
|
|
14
|
+
Exit["Print summary and set exit code"]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
subgraph Harness["packages/cli/src/agent-harness"]
|
|
18
|
+
Collect["collectCodeBlocks"]
|
|
19
|
+
Collection["CodeBlockCollection"]
|
|
20
|
+
Run["runCodeTests<br/>tasks and inline preflight, progress, and report writes"]
|
|
21
|
+
Prompt["AgentRunner.prompt()<br/>ClaudeRunner or CodexRunner"]
|
|
22
|
+
Validate["Manifest validation inside runCodeTests<br/>schema, paths, block coverage, and executables"]
|
|
23
|
+
Execute["executeCodeTestCommand<br/>setup and test commands without a shell"]
|
|
24
|
+
Report["CodeTestReport<br/>persisted as report.json"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Docs["Documentation source, read only"] --> Collect
|
|
28
|
+
Args --> Collect
|
|
29
|
+
Collect --> Collection
|
|
30
|
+
Collection --> Mode
|
|
31
|
+
Mode -->|yes| UI
|
|
32
|
+
Mode -->|no| Plain
|
|
33
|
+
UI -->|selected agents and files| Run
|
|
34
|
+
Plain -->|default agent and selected files| Run
|
|
35
|
+
Collection -->|collected blocks| Run
|
|
36
|
+
Run --> Prompt --> Validate --> Execute --> Report
|
|
37
|
+
Docs -. "read-only context" .-> Prompt
|
|
38
|
+
Run -. "inline progress callbacks" .-> UI
|
|
39
|
+
Report --> Exit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Type contracts
|
|
43
|
+
|
|
44
|
+
The harness exports five types. Status unions and nested block, environment, summary, command, and task fields live on the contract that owns them.
|
|
45
|
+
|
|
46
|
+
| Exported type | Contract |
|
|
47
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `HarnessAgent` | The supported runner names, `claude` and `codex`. |
|
|
49
|
+
| `AgentRunner` | One `prompt(prompt)` method returning the final response text. `ClaudeRunner` and `CodexRunner` implement it. |
|
|
50
|
+
| `CodeBlockCollection` | The docs root and fenced block locations grouped by markdown file. `collectCodeBlocks` returns it. |
|
|
51
|
+
| `GeneratedTestManifest` | The inferred output of the runtime `mint-test.json` schema, including generated files, block dispositions, environment needs, and commands. |
|
|
52
|
+
| `CodeTestReport` | The complete persisted `report.json` shape. Task, block, command, environment, summary, and status fields are nested here. |
|
|
53
|
+
|
|
54
|
+
`GeneratedTestManifest` is inferred from the runtime schema in `packages/cli/src/agent-harness/types.ts`, so validation and TypeScript cannot drift apart. Contract changes still require a schema test and an update to this table.
|
|
55
|
+
|
|
56
|
+
`runCodeTests` declares its input object and progress events on the function. `executeCodeTestCommand` does the same for command input. Neither API has a separate options, request, progress, preflight, command-runner, or command-result type to keep in sync.
|
|
57
|
+
|
|
58
|
+
The manifest is the boundary between generated agent output and trusted execution. The harness rejects commands outside the allowlist or task workspace, runs each command with `shell: false`, applies timeouts and cancellation, redacts secrets from captured output, and records every state change in the report.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const SYSTEM_PROMPT = `# Mint test generator
|
|
4
|
+
|
|
5
|
+
You will be provided a markdown page guide that contians codeblocks. Follow the guide and implement
|
|
6
|
+
the code as instrucuted by the guide into a given output directory.
|
|
7
|
+
|
|
8
|
+
Once finished, create a mint-test.json file in the current working directory with this shape:
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"version": 1,
|
|
12
|
+
"generatedFiles": ["tests/example.test.js", "tests/test_example.py"],
|
|
13
|
+
"setupCommands": [],
|
|
14
|
+
"testCommands": [
|
|
15
|
+
{
|
|
16
|
+
"executable": "node",
|
|
17
|
+
"args": ["--test", "tests/"],
|
|
18
|
+
"cwd": ".",
|
|
19
|
+
"timeoutMs": 120000
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"executable": "python3",
|
|
23
|
+
"args": ["-m", "unittest", "discover", "-s", "tests"],
|
|
24
|
+
"cwd": ".",
|
|
25
|
+
"timeoutMs": 120000
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Command cwd values must be relative to the current working directory. Finish with a short plain-text summary.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
- Read only the page named in the task. You may also read docs.json and an OpenAPI file referenced by that page.
|
|
34
|
+
- The output directory is already scaffolded: package.json exists with "npm test" wired to "node --test tests/", and a tests/ directory is ready. Do not recreate or rewrite this scaffolding.
|
|
35
|
+
- 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.
|
|
36
|
+
- Prefer the standard library over installing dependencies; add a setup command only when a test truly cannot run without one.
|
|
37
|
+
- Never write README, docs, or explanation files.
|
|
38
|
+
- Keep helpers inline in the test file; do not create separate helper modules.
|
|
39
|
+
- Write one test file per page unless the page genuinely needs more.
|
|
40
|
+
- Create every additional source file and fixture inside the current working directory.
|
|
41
|
+
- Never modify the documentation source. Do not commit, push, or open a pull request.
|
|
42
|
+
- Cover fenced code blocks inside Mintlify components such as CodeGroup, Tabs, Steps, RequestExample, and ResponseExample.
|
|
43
|
+
- If the page has openapi frontmatter, you may resolve its spec relative to the docs root and add tests for request snippets.
|
|
44
|
+
- Do not install dependencies or run the generated tests. The Mint CLI handles setup and execution.
|
|
45
|
+
- Never copy secrets into a file. In your code pull secrets from .env
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export function buildTaskPrompt(docsRoot: string, file: string, directory: string): string {
|
|
49
|
+
return `
|
|
50
|
+
Use the guide at "${JSON.stringify(path.join(docsRoot, file))}"
|
|
51
|
+
|
|
52
|
+
Write the required tests and mint-test.json output in the "${JSON.stringify(directory)}" directory.
|
|
53
|
+
Record your report here "${JSON.stringify(file)}"`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const CHECK_SYSTEM_PROMPT = `# Mint test triage
|
|
57
|
+
You are given a markdown page guide. Decide if the given file is able to be tested.
|
|
58
|
+
|
|
59
|
+
Testable pages are pages that include any runnable code (python, typescript, bash) and any logic that could fail if the documented code was wrong
|
|
60
|
+
|
|
61
|
+
Code blocks are not testable when it is used for formatting, showing logs, configuration, pseudo-code or a diff, an illustrative request or response shape, or a UI-only example.
|
|
62
|
+
|
|
63
|
+
When unsure, mark it not testable.
|
|
64
|
+
|
|
65
|
+
Include "reason" only when the page is not testable; omit it entirely when testable is true.`;
|
|
66
|
+
|
|
67
|
+
export const CHECK_TOOL_INSTRUCTION = `
|
|
68
|
+
|
|
69
|
+
Report your classification by calling the report_testability tool`;
|
|
70
|
+
|
|
71
|
+
export const CHECK_JSON_INSTRUCTION = `
|
|
72
|
+
|
|
73
|
+
Respond with only this JSON object and nothing else:
|
|
74
|
+
{ "version": 1, "testable": false, "reason": "expected output, not runnable code" }`;
|
|
75
|
+
|
|
76
|
+
export function buildCheckPrompt(file: string, fileContnet: string): string {
|
|
77
|
+
return `Report if the file at filePath ${JSON.stringify(file)} is testable
|
|
78
|
+
|
|
79
|
+
<file>
|
|
80
|
+
${fileContnet}
|
|
81
|
+
</file>`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { DocsPage } from './types.js';
|
|
5
|
+
|
|
6
|
+
const PAGE_EXTENSIONS = new Set(['.md', '.mdx']);
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set(['.git', '.next', 'build', 'dist', 'node_modules']);
|
|
8
|
+
const OPENING_FENCE_PATTERN = /^\s*(`{3,}|~{3,})/;
|
|
9
|
+
|
|
10
|
+
function countFencedBlocks(content: string): number {
|
|
11
|
+
const lines = content.split(/\r?\n/);
|
|
12
|
+
let count = 0;
|
|
13
|
+
for (let index = 0; index < lines.length; index++) {
|
|
14
|
+
const opening = OPENING_FENCE_PATTERN.exec(lines[index] ?? '');
|
|
15
|
+
const marker = opening?.[1];
|
|
16
|
+
if (!marker) continue;
|
|
17
|
+
const closingPattern = new RegExp(`^\\s*${marker[0]}{${marker.length},}\\s*$`);
|
|
18
|
+
let closingIndex = index + 1;
|
|
19
|
+
while (closingIndex < lines.length && !closingPattern.test(lines[closingIndex] ?? '')) {
|
|
20
|
+
closingIndex++;
|
|
21
|
+
}
|
|
22
|
+
if (closingIndex >= lines.length) break;
|
|
23
|
+
count++;
|
|
24
|
+
index = closingIndex;
|
|
25
|
+
}
|
|
26
|
+
return count;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function findSiteRoot(startDirectory: string): Promise<string | null> {
|
|
30
|
+
const { root } = path.parse(startDirectory);
|
|
31
|
+
let directory = startDirectory;
|
|
32
|
+
let previous: string | null = null;
|
|
33
|
+
while (directory !== previous) {
|
|
34
|
+
for (const configName of ['docs.json', 'mint.json']) {
|
|
35
|
+
const stats = await fs.stat(path.join(directory, configName)).catch(() => null);
|
|
36
|
+
if (stats?.isFile()) return directory;
|
|
37
|
+
}
|
|
38
|
+
if (directory === root) return null;
|
|
39
|
+
previous = directory;
|
|
40
|
+
directory = path.dirname(directory);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function discoverCodeBlocks(
|
|
46
|
+
inputPath: string
|
|
47
|
+
): Promise<{ docsRoot: string; pages: DocsPage[] }> {
|
|
48
|
+
const resolvedPath = path.resolve(inputPath);
|
|
49
|
+
const stats = await fs.stat(resolvedPath);
|
|
50
|
+
const baseDirectory = stats.isDirectory() ? resolvedPath : path.dirname(resolvedPath);
|
|
51
|
+
const docsRoot = (await findSiteRoot(baseDirectory)) ?? baseDirectory;
|
|
52
|
+
|
|
53
|
+
const filePaths: string[] = [];
|
|
54
|
+
if (stats.isDirectory()) {
|
|
55
|
+
const directories = [resolvedPath];
|
|
56
|
+
while (directories.length > 0) {
|
|
57
|
+
const directory = directories.pop();
|
|
58
|
+
if (!directory) break;
|
|
59
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
60
|
+
const entryPath = path.join(directory, entry.name);
|
|
61
|
+
if (
|
|
62
|
+
entry.isDirectory() &&
|
|
63
|
+
!IGNORED_DIRECTORIES.has(entry.name) &&
|
|
64
|
+
!(entry.name === 'mint-test' && path.basename(directory) === 'tests')
|
|
65
|
+
) {
|
|
66
|
+
directories.push(entryPath);
|
|
67
|
+
} else if (entry.isFile() && PAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
68
|
+
filePaths.push(entryPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (stats.isFile() && PAGE_EXTENSIONS.has(path.extname(resolvedPath).toLowerCase())) {
|
|
73
|
+
filePaths.push(resolvedPath);
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`Expected a docs directory or an MD/MDX file: ${resolvedPath}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pages: DocsPage[] = [];
|
|
79
|
+
for (const filePath of filePaths.sort()) {
|
|
80
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
81
|
+
const blockCount = countFencedBlocks(content);
|
|
82
|
+
if (blockCount > 0) {
|
|
83
|
+
pages.push({
|
|
84
|
+
file: path.relative(docsRoot, filePath).split(path.sep).join('/'),
|
|
85
|
+
blockCount,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { docsRoot, pages };
|
|
90
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { mapWithConcurrency } from './mapWithConcurrency.js';
|
|
5
|
+
import type {
|
|
6
|
+
CommandResult,
|
|
7
|
+
GeneratedTask,
|
|
8
|
+
TaskUpdate,
|
|
9
|
+
ManifestCommand,
|
|
10
|
+
TaskResult,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
const MAX_STREAM_BYTES = 128 * 1024;
|
|
14
|
+
const KILL_GRACE_MS = 2_000;
|
|
15
|
+
const FAILURE_PRECEDENCE = ['failed', 'cancelled'] as const;
|
|
16
|
+
const SECRET_NAME_PATTERN = /(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i;
|
|
17
|
+
|
|
18
|
+
function redactSecrets(value: string): string {
|
|
19
|
+
let redacted = value;
|
|
20
|
+
const secrets = Object.entries(process.env)
|
|
21
|
+
.filter(
|
|
22
|
+
([name, secret]) =>
|
|
23
|
+
SECRET_NAME_PATTERN.test(name) && typeof secret === 'string' && secret.length >= 8
|
|
24
|
+
)
|
|
25
|
+
.map(([, secret]) => secret as string)
|
|
26
|
+
.sort((left, right) => right.length - left.length);
|
|
27
|
+
for (const secret of secrets) redacted = redacted.split(secret).join('[REDACTED]');
|
|
28
|
+
return redacted
|
|
29
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]')
|
|
30
|
+
.replace(/\b(?:sk|sess)-[A-Za-z0-9._-]{12,}\b/gi, '[REDACTED]');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function outputCollector(): { append: (chunk: Buffer | string) => void; value: () => string } {
|
|
34
|
+
let output = '';
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
let truncated = false;
|
|
37
|
+
return {
|
|
38
|
+
append(chunk) {
|
|
39
|
+
if (truncated) return;
|
|
40
|
+
const value = chunk.toString();
|
|
41
|
+
const remaining = MAX_STREAM_BYTES - bytes;
|
|
42
|
+
if (Buffer.byteLength(value) <= remaining) {
|
|
43
|
+
output += value;
|
|
44
|
+
bytes += Buffer.byteLength(value);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
output += Buffer.from(value).subarray(0, Math.max(0, remaining)).toString();
|
|
48
|
+
output += '\n[mint test: output truncated]\n';
|
|
49
|
+
truncated = true;
|
|
50
|
+
},
|
|
51
|
+
value: () => output,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function unrunCommandResult(
|
|
56
|
+
command: ManifestCommand,
|
|
57
|
+
phase: 'setup' | 'test',
|
|
58
|
+
cwd: string,
|
|
59
|
+
status: 'failed' | 'cancelled',
|
|
60
|
+
error: string
|
|
61
|
+
): CommandResult {
|
|
62
|
+
return {
|
|
63
|
+
id: command.id,
|
|
64
|
+
phase,
|
|
65
|
+
status,
|
|
66
|
+
executable: command.executable,
|
|
67
|
+
args: command.args,
|
|
68
|
+
cwd,
|
|
69
|
+
durationMs: 0,
|
|
70
|
+
exitCode: null,
|
|
71
|
+
stdout: '',
|
|
72
|
+
stderr: '',
|
|
73
|
+
error,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function spawnCommand({
|
|
78
|
+
command,
|
|
79
|
+
phase,
|
|
80
|
+
cwd,
|
|
81
|
+
timeoutMs,
|
|
82
|
+
signal,
|
|
83
|
+
docsRootForCommands,
|
|
84
|
+
}: {
|
|
85
|
+
command: ManifestCommand;
|
|
86
|
+
phase: 'setup' | 'test';
|
|
87
|
+
cwd: string;
|
|
88
|
+
timeoutMs: number;
|
|
89
|
+
signal: AbortSignal;
|
|
90
|
+
docsRootForCommands: string;
|
|
91
|
+
}): Promise<CommandResult> {
|
|
92
|
+
const startedAt = Date.now();
|
|
93
|
+
const base = {
|
|
94
|
+
id: command.id,
|
|
95
|
+
phase,
|
|
96
|
+
executable: command.executable,
|
|
97
|
+
args: command.args,
|
|
98
|
+
cwd,
|
|
99
|
+
};
|
|
100
|
+
const commandTimeoutMs = command.timeoutMs ?? timeoutMs;
|
|
101
|
+
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const stdout = outputCollector();
|
|
104
|
+
const stderr = outputCollector();
|
|
105
|
+
let completed = false;
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
let cancelled = false;
|
|
108
|
+
let forceKillTimer: NodeJS.Timeout | undefined;
|
|
109
|
+
|
|
110
|
+
const child = spawn(command.executable, command.args, {
|
|
111
|
+
cwd,
|
|
112
|
+
detached: true,
|
|
113
|
+
env: { ...process.env, MINT_DOCS_ROOT: docsRootForCommands },
|
|
114
|
+
shell: false,
|
|
115
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
116
|
+
});
|
|
117
|
+
child.stdout.on('data', stdout.append);
|
|
118
|
+
child.stderr.on('data', stderr.append);
|
|
119
|
+
|
|
120
|
+
const killTree = (killSignal: NodeJS.Signals) => {
|
|
121
|
+
if (child.pid) {
|
|
122
|
+
try {
|
|
123
|
+
process.kill(-child.pid, killSignal);
|
|
124
|
+
return;
|
|
125
|
+
} catch {
|
|
126
|
+
/* noop */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
child.kill(killSignal);
|
|
130
|
+
};
|
|
131
|
+
const kill = () => {
|
|
132
|
+
killTree('SIGTERM');
|
|
133
|
+
forceKillTimer = setTimeout(() => killTree('SIGKILL'), KILL_GRACE_MS);
|
|
134
|
+
};
|
|
135
|
+
const timeout = setTimeout(() => {
|
|
136
|
+
timedOut = true;
|
|
137
|
+
kill();
|
|
138
|
+
}, commandTimeoutMs);
|
|
139
|
+
const abort = () => {
|
|
140
|
+
cancelled = true;
|
|
141
|
+
kill();
|
|
142
|
+
};
|
|
143
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
144
|
+
|
|
145
|
+
const finish = (exitCode: number | null, spawnError?: string) => {
|
|
146
|
+
if (completed) return;
|
|
147
|
+
completed = true;
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
150
|
+
signal.removeEventListener('abort', abort);
|
|
151
|
+
const error = cancelled
|
|
152
|
+
? 'run cancelled'
|
|
153
|
+
: timedOut
|
|
154
|
+
? `command timed out after ${commandTimeoutMs}ms`
|
|
155
|
+
: spawnError;
|
|
156
|
+
resolve({
|
|
157
|
+
...base,
|
|
158
|
+
status: cancelled ? 'cancelled' : exitCode === 0 && !error ? 'passed' : 'failed',
|
|
159
|
+
durationMs: Date.now() - startedAt,
|
|
160
|
+
exitCode,
|
|
161
|
+
stdout: redactSecrets(stdout.value()),
|
|
162
|
+
stderr: redactSecrets(stderr.value()),
|
|
163
|
+
...(error ? { error } : {}),
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
child.once('error', (error) => finish(null, error.message));
|
|
167
|
+
child.once('close', (exitCode) => finish(exitCode));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function executeCodeBlocks({
|
|
172
|
+
tasks,
|
|
173
|
+
docsRoot,
|
|
174
|
+
concurrency,
|
|
175
|
+
commandTimeoutMs,
|
|
176
|
+
signal,
|
|
177
|
+
onTaskUpdate,
|
|
178
|
+
}: {
|
|
179
|
+
tasks: GeneratedTask[];
|
|
180
|
+
docsRoot: string;
|
|
181
|
+
concurrency: number;
|
|
182
|
+
commandTimeoutMs: number;
|
|
183
|
+
signal: AbortSignal;
|
|
184
|
+
onTaskUpdate: (update: TaskUpdate) => void;
|
|
185
|
+
}): Promise<TaskResult[]> {
|
|
186
|
+
return mapWithConcurrency(tasks, concurrency, async (task) => {
|
|
187
|
+
const base = {
|
|
188
|
+
id: task.id,
|
|
189
|
+
agent: task.agent,
|
|
190
|
+
file: task.file,
|
|
191
|
+
directory: task.directory,
|
|
192
|
+
attempts: task.attempts,
|
|
193
|
+
tokens: task.tokens,
|
|
194
|
+
};
|
|
195
|
+
const manifest = task.manifest;
|
|
196
|
+
if (!manifest) {
|
|
197
|
+
if (!task.error) {
|
|
198
|
+
onTaskUpdate({
|
|
199
|
+
id: task.id,
|
|
200
|
+
agent: task.agent,
|
|
201
|
+
file: task.file,
|
|
202
|
+
phase: 'not_testable',
|
|
203
|
+
tokens: task.tokens,
|
|
204
|
+
});
|
|
205
|
+
return { ...base, status: 'passed' as const, commands: [] };
|
|
206
|
+
}
|
|
207
|
+
const status = signal.aborted ? ('cancelled' as const) : ('agent_error' as const);
|
|
208
|
+
onTaskUpdate({
|
|
209
|
+
id: task.id,
|
|
210
|
+
agent: task.agent,
|
|
211
|
+
file: task.file,
|
|
212
|
+
phase: status,
|
|
213
|
+
tokens: task.tokens,
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
status,
|
|
218
|
+
error: task.error,
|
|
219
|
+
commands: [],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
onTaskUpdate({
|
|
223
|
+
id: task.id,
|
|
224
|
+
agent: task.agent,
|
|
225
|
+
file: task.file,
|
|
226
|
+
phase: 'running',
|
|
227
|
+
tokens: task.tokens,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const commands: CommandResult[] = [];
|
|
231
|
+
let setupFailure: 'failed' | 'cancelled' | undefined;
|
|
232
|
+
|
|
233
|
+
const run = async (
|
|
234
|
+
command: ManifestCommand,
|
|
235
|
+
phase: 'setup' | 'test'
|
|
236
|
+
): Promise<CommandResult> => {
|
|
237
|
+
const cwd = path.resolve(task.directory, command.cwd ?? '.');
|
|
238
|
+
if (signal.aborted) {
|
|
239
|
+
return unrunCommandResult(command, phase, cwd, 'cancelled', 'run cancelled');
|
|
240
|
+
}
|
|
241
|
+
if (phase === 'test' && setupFailure) {
|
|
242
|
+
return unrunCommandResult(
|
|
243
|
+
command,
|
|
244
|
+
phase,
|
|
245
|
+
cwd,
|
|
246
|
+
setupFailure,
|
|
247
|
+
`a setup command ${setupFailure === 'cancelled' ? 'was cancelled' : 'failed'}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return spawnCommand({
|
|
251
|
+
command,
|
|
252
|
+
phase,
|
|
253
|
+
cwd,
|
|
254
|
+
timeoutMs: commandTimeoutMs,
|
|
255
|
+
signal,
|
|
256
|
+
docsRootForCommands: docsRoot,
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
for (const command of manifest.setupCommands) {
|
|
261
|
+
const result = await run(command, 'setup');
|
|
262
|
+
commands.push(result);
|
|
263
|
+
if (result.status !== 'passed') {
|
|
264
|
+
setupFailure = result.status;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const command of manifest.testCommands) {
|
|
269
|
+
commands.push(await run(command, 'test'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const status =
|
|
273
|
+
FAILURE_PRECEDENCE.find((candidate) =>
|
|
274
|
+
commands.some((command) => command.status === candidate)
|
|
275
|
+
) ?? 'passed';
|
|
276
|
+
onTaskUpdate({
|
|
277
|
+
id: task.id,
|
|
278
|
+
agent: task.agent,
|
|
279
|
+
file: task.file,
|
|
280
|
+
phase: status,
|
|
281
|
+
tokens: task.tokens,
|
|
282
|
+
});
|
|
283
|
+
return { ...base, status, commands };
|
|
284
|
+
});
|
|
285
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import type { DocsPage } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function filterSelectedPages(pages: DocsPage[], selectedFiles: string[]): DocsPage[] {
|
|
6
|
+
const selected = new Set(
|
|
7
|
+
selectedFiles.map((file) => file.split(path.sep).join('/').replace(/^\.\//, ''))
|
|
8
|
+
);
|
|
9
|
+
const known = new Set(pages.map((page) => page.file));
|
|
10
|
+
for (const file of selected) {
|
|
11
|
+
if (!known.has(file)) throw new Error(`Selected docs file was not found: ${file}`);
|
|
12
|
+
}
|
|
13
|
+
return pages.filter((page) => selected.has(page.file));
|
|
14
|
+
}
|