@quinteroac/agents-coding-toolkit 0.1.0-preview
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/AGENTS.md +7 -0
- package/README.md +127 -0
- package/package.json +34 -0
- package/scaffold/.agents/flow/archived/tmpl_.gitkeep +0 -0
- package/scaffold/.agents/flow/tmpl_README.md +7 -0
- package/scaffold/.agents/flow/tmpl_iteration_close_checklist.example.md +11 -0
- package/scaffold/.agents/skills/automated-fix/tmpl_SKILL.md +67 -0
- package/scaffold/.agents/skills/create-issue/tmpl_SKILL.md +68 -0
- package/scaffold/.agents/skills/create-pr-document/tmpl_SKILL.md +125 -0
- package/scaffold/.agents/skills/create-project-context/tmpl_SKILL.md +168 -0
- package/scaffold/.agents/skills/create-test-plan/tmpl_SKILL.md +86 -0
- package/scaffold/.agents/skills/debug/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/evaluate/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/execute-test-batch/tmpl_SKILL.md +49 -0
- package/scaffold/.agents/skills/execute-test-case/tmpl_SKILL.md +47 -0
- package/scaffold/.agents/skills/implement-user-story/tmpl_SKILL.md +68 -0
- package/scaffold/.agents/skills/plan-refactor/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/refactor-prd/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/refine-pr-document/tmpl_SKILL.md +108 -0
- package/scaffold/.agents/skills/refine-project-context/tmpl_SKILL.md +157 -0
- package/scaffold/.agents/skills/refine-test-plan/tmpl_SKILL.md +76 -0
- package/scaffold/.agents/tmpl_PROJECT_CONTEXT.md +3 -0
- package/scaffold/.agents/tmpl_state.example.json +26 -0
- package/scaffold/.agents/tmpl_state_rules.md +29 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_CHANGELOG.md +18 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_TECHNICAL_DEBT.md +11 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_evaluation-report.md +19 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_product-requirement-document.md +19 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_refactor_plan.md +19 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_test-plan.md +19 -0
- package/scaffold/docs/nvst-flow/tmpl_COMMANDS.md +0 -0
- package/scaffold/docs/nvst-flow/tmpl_QUICK_USE.md +0 -0
- package/scaffold/docs/tmpl_PLACEHOLDER.md +0 -0
- package/scaffold/schemas/node-shims.d.ts +15 -0
- package/scaffold/schemas/tmpl_issues.ts +19 -0
- package/scaffold/schemas/tmpl_prd.ts +26 -0
- package/scaffold/schemas/tmpl_progress.ts +39 -0
- package/scaffold/schemas/tmpl_state.ts +81 -0
- package/scaffold/schemas/tmpl_test-plan.ts +20 -0
- package/scaffold/schemas/tmpl_validate-progress.ts +13 -0
- package/scaffold/schemas/tmpl_validate-state.ts +13 -0
- package/scaffold/tmpl_AGENTS.md +7 -0
- package/schemas/prd.ts +26 -0
- package/schemas/progress.ts +39 -0
- package/schemas/state.ts +81 -0
- package/schemas/test-plan.test.ts +53 -0
- package/schemas/test-plan.ts +20 -0
- package/schemas/validate-progress.ts +13 -0
- package/schemas/validate-state.ts +13 -0
- package/src/agent.test.ts +37 -0
- package/src/agent.ts +225 -0
- package/src/cli-path.ts +4 -0
- package/src/cli.ts +578 -0
- package/src/commands/approve-project-context.ts +37 -0
- package/src/commands/approve-requirement.ts +217 -0
- package/src/commands/approve-test-plan.test.ts +193 -0
- package/src/commands/approve-test-plan.ts +202 -0
- package/src/commands/create-issue.test.ts +484 -0
- package/src/commands/create-issue.ts +371 -0
- package/src/commands/create-project-context.ts +96 -0
- package/src/commands/create-prototype.test.ts +153 -0
- package/src/commands/create-prototype.ts +425 -0
- package/src/commands/create-test-plan.test.ts +381 -0
- package/src/commands/create-test-plan.ts +248 -0
- package/src/commands/define-requirement.ts +47 -0
- package/src/commands/destroy.ts +113 -0
- package/src/commands/execute-automated-fix.test.ts +580 -0
- package/src/commands/execute-automated-fix.ts +363 -0
- package/src/commands/execute-manual-fix.test.ts +343 -0
- package/src/commands/execute-manual-fix.ts +203 -0
- package/src/commands/execute-test-plan.test.ts +1891 -0
- package/src/commands/execute-test-plan.ts +722 -0
- package/src/commands/init.ts +85 -0
- package/src/commands/refine-project-context.ts +74 -0
- package/src/commands/refine-requirement.ts +60 -0
- package/src/commands/refine-test-plan.test.ts +200 -0
- package/src/commands/refine-test-plan.ts +93 -0
- package/src/commands/start-iteration.test.ts +144 -0
- package/src/commands/start-iteration.ts +101 -0
- package/src/commands/write-json.ts +136 -0
- package/src/install.test.ts +124 -0
- package/src/pack.test.ts +103 -0
- package/src/state.test.ts +66 -0
- package/src/state.ts +52 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildPrompt,
|
|
8
|
+
invokeAgent,
|
|
9
|
+
loadSkill,
|
|
10
|
+
type AgentInvokeOptions,
|
|
11
|
+
type AgentProvider,
|
|
12
|
+
type AgentResult,
|
|
13
|
+
} from "../agent";
|
|
14
|
+
import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
|
|
15
|
+
import { TestPlanSchema, type TestPlan } from "../../schemas/test-plan";
|
|
16
|
+
import { extractJson } from "./create-issue";
|
|
17
|
+
|
|
18
|
+
export interface ExecuteTestPlanOptions {
|
|
19
|
+
provider: AgentProvider;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ExecutionPayloadSchema = z.object({
|
|
23
|
+
status: z.enum(["passed", "failed", "skipped"]),
|
|
24
|
+
evidence: z.string(),
|
|
25
|
+
notes: z.string(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
type ExecutionPayload = z.infer<typeof ExecutionPayloadSchema>;
|
|
29
|
+
|
|
30
|
+
const BatchResultItemSchema = z.object({
|
|
31
|
+
testCaseId: z.string(),
|
|
32
|
+
status: z.enum(["passed", "failed", "skipped"]),
|
|
33
|
+
evidence: z.string(),
|
|
34
|
+
notes: z.string(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const BatchResultSchema = z.array(BatchResultItemSchema);
|
|
38
|
+
|
|
39
|
+
type BatchResultItem = z.infer<typeof BatchResultItemSchema>;
|
|
40
|
+
|
|
41
|
+
const TestExecutionProgressStatusSchema = z.enum(["pending", "in_progress", "passed", "failed"]);
|
|
42
|
+
|
|
43
|
+
const TestExecutionProgressEntrySchema = z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
type: z.enum(["automated", "exploratory_manual"]),
|
|
46
|
+
status: TestExecutionProgressStatusSchema,
|
|
47
|
+
attempt_count: z.number().int().nonnegative(),
|
|
48
|
+
last_agent_exit_code: z.number().int().nullable(),
|
|
49
|
+
last_error_summary: z.string(),
|
|
50
|
+
updated_at: z.string(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const TestExecutionProgressSchema = z.object({
|
|
54
|
+
entries: z.array(TestExecutionProgressEntrySchema),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
type TestExecutionProgress = z.infer<typeof TestExecutionProgressSchema>;
|
|
58
|
+
|
|
59
|
+
interface FlatTestCase {
|
|
60
|
+
id: string;
|
|
61
|
+
description: string;
|
|
62
|
+
mode: "automated" | "exploratory_manual";
|
|
63
|
+
correlatedRequirements: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ManualTestUserInput {
|
|
67
|
+
status: "passed" | "failed" | "skipped";
|
|
68
|
+
evidence: string;
|
|
69
|
+
notes: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function promptManualTest(testCase: FlatTestCase): Promise<ManualTestUserInput> {
|
|
73
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
74
|
+
try {
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log("─".repeat(60));
|
|
77
|
+
console.log(`Manual Test: ${testCase.id}`);
|
|
78
|
+
console.log(`Description: ${testCase.description}`);
|
|
79
|
+
console.log(`Correlated Requirements: ${testCase.correlatedRequirements.join(", ")}`);
|
|
80
|
+
console.log(`Expected Result: ${testCase.description}`);
|
|
81
|
+
console.log("─".repeat(60));
|
|
82
|
+
|
|
83
|
+
let status: "passed" | "failed" | "skipped" | undefined;
|
|
84
|
+
while (!status) {
|
|
85
|
+
const answer = await rl.question("Status (passed / failed / skipped): ");
|
|
86
|
+
const trimmed = answer.trim().toLowerCase();
|
|
87
|
+
if (trimmed === "passed" || trimmed === "failed" || trimmed === "skipped") {
|
|
88
|
+
status = trimmed;
|
|
89
|
+
} else {
|
|
90
|
+
console.log("Invalid status. Please enter: passed, failed, or skipped.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const evidence = (await rl.question("Evidence (what you observed): ")).trim();
|
|
95
|
+
const notes = (await rl.question("Notes (optional, press Enter to skip): ")).trim();
|
|
96
|
+
|
|
97
|
+
return { status, evidence, notes };
|
|
98
|
+
} finally {
|
|
99
|
+
rl.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface TestExecutionResult {
|
|
104
|
+
testCaseId: string;
|
|
105
|
+
description: string;
|
|
106
|
+
correlatedRequirements: string[];
|
|
107
|
+
mode: "automated" | "exploratory_manual";
|
|
108
|
+
payload: {
|
|
109
|
+
status: "passed" | "failed" | "skipped" | "invocation_failed";
|
|
110
|
+
evidence: string;
|
|
111
|
+
notes: string;
|
|
112
|
+
};
|
|
113
|
+
passFail: "pass" | "fail" | null;
|
|
114
|
+
agentExitCode: number;
|
|
115
|
+
artifactReferences: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface TestExecutionReport {
|
|
119
|
+
iteration: string;
|
|
120
|
+
testPlanFile: string;
|
|
121
|
+
executedTestIds: string[];
|
|
122
|
+
results: TestExecutionResult[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface ExecuteTestPlanDeps {
|
|
126
|
+
existsFn: (path: string) => Promise<boolean>;
|
|
127
|
+
invokeAgentFn: (options: AgentInvokeOptions) => Promise<AgentResult>;
|
|
128
|
+
loadSkillFn: (projectRoot: string, skillName: string) => Promise<string>;
|
|
129
|
+
mkdirFn: typeof mkdir;
|
|
130
|
+
nowFn: () => Date;
|
|
131
|
+
promptManualTestFn: (testCase: FlatTestCase) => Promise<ManualTestUserInput>;
|
|
132
|
+
readFileFn: typeof readFile;
|
|
133
|
+
writeFileFn: typeof Bun.write;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const defaultDeps: ExecuteTestPlanDeps = {
|
|
137
|
+
existsFn: exists,
|
|
138
|
+
invokeAgentFn: invokeAgent,
|
|
139
|
+
loadSkillFn: loadSkill,
|
|
140
|
+
mkdirFn: mkdir,
|
|
141
|
+
nowFn: () => new Date(),
|
|
142
|
+
promptManualTestFn: promptManualTest,
|
|
143
|
+
readFileFn: readFile,
|
|
144
|
+
writeFileFn: Bun.write,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function flattenTests(testPlan: TestPlan): FlatTestCase[] {
|
|
148
|
+
const automated = testPlan.automatedTests.map((item) => ({
|
|
149
|
+
id: item.id,
|
|
150
|
+
description: item.description,
|
|
151
|
+
mode: "automated" as const,
|
|
152
|
+
correlatedRequirements: item.correlatedRequirements,
|
|
153
|
+
}));
|
|
154
|
+
const manual = testPlan.exploratoryManualTests.map((item) => ({
|
|
155
|
+
id: item.id,
|
|
156
|
+
description: item.description,
|
|
157
|
+
mode: "exploratory_manual" as const,
|
|
158
|
+
correlatedRequirements: item.correlatedRequirements,
|
|
159
|
+
}));
|
|
160
|
+
return [...automated, ...manual];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildBatchExecutionPrompt(
|
|
164
|
+
skillBody: string,
|
|
165
|
+
testCases: FlatTestCase[],
|
|
166
|
+
projectContextContent: string,
|
|
167
|
+
): string {
|
|
168
|
+
return buildPrompt(skillBody, {
|
|
169
|
+
project_context: projectContextContent,
|
|
170
|
+
test_cases: JSON.stringify(testCases, null, 2),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseBatchExecutionPayload(raw: string): BatchResultItem[] {
|
|
175
|
+
let parsed: unknown;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(extractJson(raw));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new Error("Agent batch output was not valid JSON.", { cause: error });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const validation = BatchResultSchema.safeParse(parsed);
|
|
183
|
+
if (!validation.success) {
|
|
184
|
+
throw new Error("Agent batch output did not match required batch result schema.", {
|
|
185
|
+
cause: validation.error,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return validation.data;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function derivePassFail(status: ExecutionPayload["status"]): "pass" | "fail" | null {
|
|
193
|
+
if (status === "passed") return "pass";
|
|
194
|
+
if (status === "failed") return "fail";
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sortedValues(values: string[]): string[] {
|
|
199
|
+
return [...values].sort((a, b) => a.localeCompare(b));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function idsMatchExactly(left: string[], right: string[]): boolean {
|
|
203
|
+
if (left.length !== right.length) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
208
|
+
if (left[i] !== right[i]) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function toArtifactSafeSegment(value: string): string {
|
|
217
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildArtifactFileName(testCaseId: string, attemptNumber: number): string {
|
|
221
|
+
const safeId = toArtifactSafeSegment(testCaseId);
|
|
222
|
+
const paddedAttempt = attemptNumber.toString().padStart(3, "0");
|
|
223
|
+
return `${safeId}_attempt_${paddedAttempt}.json`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildMarkdownReport(report: TestExecutionReport): string {
|
|
227
|
+
const totalTests = report.results.length;
|
|
228
|
+
const passedCount = report.results.filter((result) => result.payload.status === "passed").length;
|
|
229
|
+
const failedCount = totalTests - passedCount;
|
|
230
|
+
|
|
231
|
+
const lines = [
|
|
232
|
+
`# Test Execution Report (Iteration ${report.iteration})`,
|
|
233
|
+
"",
|
|
234
|
+
`- Test Plan: \`${report.testPlanFile}\``,
|
|
235
|
+
`- Total Tests: ${totalTests}`,
|
|
236
|
+
`- Passed: ${passedCount}`,
|
|
237
|
+
`- Failed: ${failedCount}`,
|
|
238
|
+
"",
|
|
239
|
+
"| Test ID | Description | Status | Correlated Requirements | Artifacts |",
|
|
240
|
+
"| --- | --- | --- | --- | --- |",
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
for (const result of report.results) {
|
|
244
|
+
const correlatedRequirements = result.correlatedRequirements.join(", ");
|
|
245
|
+
const artifactReferences = result.artifactReferences.map((path) => `\`${path}\``).join("<br>");
|
|
246
|
+
lines.push(
|
|
247
|
+
`| ${result.testCaseId} | ${result.description} | ${result.payload.status} | ${correlatedRequirements} | ${artifactReferences} |`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
lines.push("");
|
|
252
|
+
return `${lines.join("\n")}\n`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function recordTestResult(
|
|
256
|
+
testCase: FlatTestCase,
|
|
257
|
+
payload: { status: "passed" | "failed" | "skipped" | "invocation_failed"; evidence: string; notes: string },
|
|
258
|
+
agentExitCode: number,
|
|
259
|
+
batchPrompt: string,
|
|
260
|
+
agentStdout: string,
|
|
261
|
+
agentStderr: string,
|
|
262
|
+
progressEntry: TestExecutionProgress["entries"][number],
|
|
263
|
+
artifactsDirName: string,
|
|
264
|
+
projectRoot: string,
|
|
265
|
+
executionByTestId: Map<string, TestExecutionResult>,
|
|
266
|
+
executedTestIds: string[],
|
|
267
|
+
writeProgress: () => Promise<void>,
|
|
268
|
+
mergedDeps: ExecuteTestPlanDeps,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const attemptNumber = progressEntry.attempt_count + 1;
|
|
271
|
+
const artifactFileName = buildArtifactFileName(testCase.id, attemptNumber);
|
|
272
|
+
const artifactRelativePath = join(FLOW_REL_DIR, artifactsDirName, artifactFileName);
|
|
273
|
+
const artifactAbsolutePath = join(projectRoot, artifactRelativePath);
|
|
274
|
+
|
|
275
|
+
progressEntry.attempt_count += 1;
|
|
276
|
+
progressEntry.last_agent_exit_code = agentExitCode;
|
|
277
|
+
if (payload.status === "invocation_failed") {
|
|
278
|
+
progressEntry.last_error_summary = payload.notes;
|
|
279
|
+
progressEntry.status = "failed";
|
|
280
|
+
} else {
|
|
281
|
+
progressEntry.last_error_summary = payload.status === "passed" ? "" : payload.notes;
|
|
282
|
+
progressEntry.status = payload.status === "passed" ? "passed" : "failed";
|
|
283
|
+
}
|
|
284
|
+
progressEntry.updated_at = new Date().toISOString();
|
|
285
|
+
await writeProgress();
|
|
286
|
+
|
|
287
|
+
await mergedDeps.writeFileFn(
|
|
288
|
+
artifactAbsolutePath,
|
|
289
|
+
`${JSON.stringify(
|
|
290
|
+
{
|
|
291
|
+
testCaseId: testCase.id,
|
|
292
|
+
attemptNumber,
|
|
293
|
+
prompt: batchPrompt,
|
|
294
|
+
agentExitCode,
|
|
295
|
+
stdout: agentStdout,
|
|
296
|
+
stderr: agentStderr,
|
|
297
|
+
payload,
|
|
298
|
+
},
|
|
299
|
+
null,
|
|
300
|
+
2,
|
|
301
|
+
)}\n`,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
executedTestIds.push(testCase.id);
|
|
305
|
+
executionByTestId.set(testCase.id, {
|
|
306
|
+
testCaseId: testCase.id,
|
|
307
|
+
description: testCase.description,
|
|
308
|
+
correlatedRequirements: testCase.correlatedRequirements,
|
|
309
|
+
mode: testCase.mode,
|
|
310
|
+
payload,
|
|
311
|
+
passFail: payload.status === "invocation_failed" ? null : derivePassFail(payload.status),
|
|
312
|
+
agentExitCode,
|
|
313
|
+
artifactReferences: [artifactRelativePath],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function runExecuteTestPlan(
|
|
318
|
+
opts: ExecuteTestPlanOptions,
|
|
319
|
+
deps: Partial<ExecuteTestPlanDeps> = {},
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
const projectRoot = process.cwd();
|
|
322
|
+
const mergedDeps: ExecuteTestPlanDeps = { ...defaultDeps, ...deps };
|
|
323
|
+
const state = await readState(projectRoot);
|
|
324
|
+
|
|
325
|
+
const tpGeneration = state.phases.prototype.tp_generation;
|
|
326
|
+
if (tpGeneration.status !== "created") {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Cannot execute test plan: prototype.tp_generation.status must be created. Current status: '${tpGeneration.status}'. Run \`bun nvst approve test-plan\` first.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!tpGeneration.file) {
|
|
333
|
+
throw new Error("Cannot execute test plan: prototype.tp_generation.file is missing.");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const testPlanPath = join(projectRoot, FLOW_REL_DIR, tpGeneration.file);
|
|
337
|
+
if (!(await mergedDeps.existsFn(testPlanPath))) {
|
|
338
|
+
throw new Error(`Cannot execute test plan: file not found at ${testPlanPath}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let parsedTestPlan: unknown;
|
|
342
|
+
try {
|
|
343
|
+
parsedTestPlan = JSON.parse(await mergedDeps.readFileFn(testPlanPath, "utf8"));
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(`Invalid test plan JSON at ${join(FLOW_REL_DIR, tpGeneration.file)}.`, {
|
|
346
|
+
cause: error,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const testPlanValidation = TestPlanSchema.safeParse(parsedTestPlan);
|
|
351
|
+
if (!testPlanValidation.success) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Test plan JSON schema mismatch at ${join(FLOW_REL_DIR, tpGeneration.file)}.`,
|
|
354
|
+
{ cause: testPlanValidation.error },
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const projectContextPath = join(projectRoot, ".agents", "PROJECT_CONTEXT.md");
|
|
359
|
+
if (!(await mergedDeps.existsFn(projectContextPath))) {
|
|
360
|
+
throw new Error("Project context missing: expected .agents/PROJECT_CONTEXT.md.");
|
|
361
|
+
}
|
|
362
|
+
const projectContextContent = await mergedDeps.readFileFn(projectContextPath, "utf8");
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await mergedDeps.loadSkillFn(projectRoot, "execute-test-case");
|
|
366
|
+
} catch {
|
|
367
|
+
throw new Error(
|
|
368
|
+
"Required skill missing: expected .agents/skills/execute-test-case/SKILL.md.",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let batchSkillBody: string;
|
|
373
|
+
try {
|
|
374
|
+
batchSkillBody = await mergedDeps.loadSkillFn(projectRoot, "execute-test-batch");
|
|
375
|
+
} catch {
|
|
376
|
+
throw new Error(
|
|
377
|
+
"Required skill missing: expected .agents/skills/execute-test-batch/SKILL.md.",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const testCases = flattenTests(testPlanValidation.data);
|
|
382
|
+
const now = new Date().toISOString();
|
|
383
|
+
const progressFileName = `it_${state.current_iteration}_test-execution-progress.json`;
|
|
384
|
+
const progressPath = join(projectRoot, FLOW_REL_DIR, progressFileName);
|
|
385
|
+
const artifactsDirName = `it_${state.current_iteration}_test-execution-artifacts`;
|
|
386
|
+
const artifactsDirPath = join(projectRoot, FLOW_REL_DIR, artifactsDirName);
|
|
387
|
+
|
|
388
|
+
state.phases.prototype.test_execution.status = "in_progress";
|
|
389
|
+
state.phases.prototype.test_execution.file = progressFileName;
|
|
390
|
+
state.last_updated = mergedDeps.nowFn().toISOString();
|
|
391
|
+
state.updated_by = "nvst:execute-test-plan";
|
|
392
|
+
await writeState(projectRoot, state);
|
|
393
|
+
|
|
394
|
+
let progress: TestExecutionProgress;
|
|
395
|
+
if (await mergedDeps.existsFn(progressPath)) {
|
|
396
|
+
let parsedProgress: unknown;
|
|
397
|
+
try {
|
|
398
|
+
parsedProgress = JSON.parse(await mergedDeps.readFileFn(progressPath, "utf8"));
|
|
399
|
+
} catch (error) {
|
|
400
|
+
throw new Error(`Invalid progress JSON at ${join(FLOW_REL_DIR, progressFileName)}.`, {
|
|
401
|
+
cause: error,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const progressValidation = TestExecutionProgressSchema.safeParse(parsedProgress);
|
|
406
|
+
if (!progressValidation.success) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Progress JSON schema mismatch at ${join(FLOW_REL_DIR, progressFileName)}.`,
|
|
409
|
+
{ cause: progressValidation.error },
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const expectedIds = sortedValues(testCases.map((testCase) => testCase.id));
|
|
414
|
+
const existingIds = sortedValues(progressValidation.data.entries.map((entry) => entry.id));
|
|
415
|
+
if (!idsMatchExactly(existingIds, expectedIds)) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
"Test execution progress file out of sync: entry ids do not match approved test plan test ids.",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
progress = progressValidation.data;
|
|
422
|
+
} else {
|
|
423
|
+
progress = {
|
|
424
|
+
entries: testCases.map((testCase) => ({
|
|
425
|
+
id: testCase.id,
|
|
426
|
+
type: testCase.mode,
|
|
427
|
+
status: "pending",
|
|
428
|
+
attempt_count: 0,
|
|
429
|
+
last_agent_exit_code: null,
|
|
430
|
+
last_error_summary: "",
|
|
431
|
+
updated_at: now,
|
|
432
|
+
})),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const executionByTestId = new Map<string, TestExecutionResult>();
|
|
437
|
+
const executedTestIds: string[] = [];
|
|
438
|
+
|
|
439
|
+
const writeProgress = async () => {
|
|
440
|
+
await mergedDeps.writeFileFn(progressPath, `${JSON.stringify(progress, null, 2)}\n`);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
await mergedDeps.mkdirFn(join(projectRoot, FLOW_REL_DIR), { recursive: true });
|
|
444
|
+
await mergedDeps.mkdirFn(artifactsDirPath, { recursive: true });
|
|
445
|
+
await writeProgress();
|
|
446
|
+
|
|
447
|
+
// --- Batch execution for automated tests ---
|
|
448
|
+
const pendingAutomatedTests = testCases.filter((tc) => {
|
|
449
|
+
if (tc.mode !== "automated") return false;
|
|
450
|
+
const entry = progress.entries.find((e) => e.id === tc.id);
|
|
451
|
+
return entry !== undefined && entry.status !== "passed";
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (pendingAutomatedTests.length > 0) {
|
|
455
|
+
// Mark all pending automated tests as in_progress
|
|
456
|
+
for (const tc of pendingAutomatedTests) {
|
|
457
|
+
const entry = progress.entries.find((e) => e.id === tc.id);
|
|
458
|
+
if (entry) {
|
|
459
|
+
entry.status = "in_progress";
|
|
460
|
+
entry.updated_at = new Date().toISOString();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
await writeProgress();
|
|
464
|
+
|
|
465
|
+
const batchPrompt = buildBatchExecutionPrompt(
|
|
466
|
+
batchSkillBody,
|
|
467
|
+
pendingAutomatedTests,
|
|
468
|
+
projectContextContent,
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const agentResult = await mergedDeps.invokeAgentFn({
|
|
472
|
+
provider: opts.provider,
|
|
473
|
+
prompt: batchPrompt,
|
|
474
|
+
cwd: projectRoot,
|
|
475
|
+
interactive: false,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (agentResult.exitCode !== 0) {
|
|
479
|
+
// All automated tests in the batch fail with invocation_failed
|
|
480
|
+
for (const tc of pendingAutomatedTests) {
|
|
481
|
+
const entry = progress.entries.find((e) => e.id === tc.id);
|
|
482
|
+
if (!entry) continue;
|
|
483
|
+
const errorSummary = `Agent invocation failed with exit code ${agentResult.exitCode}.`;
|
|
484
|
+
await recordTestResult(
|
|
485
|
+
tc,
|
|
486
|
+
{ status: "invocation_failed", evidence: "", notes: errorSummary },
|
|
487
|
+
agentResult.exitCode,
|
|
488
|
+
batchPrompt,
|
|
489
|
+
agentResult.stdout,
|
|
490
|
+
agentResult.stderr,
|
|
491
|
+
entry,
|
|
492
|
+
artifactsDirName,
|
|
493
|
+
projectRoot,
|
|
494
|
+
executionByTestId,
|
|
495
|
+
executedTestIds,
|
|
496
|
+
writeProgress,
|
|
497
|
+
mergedDeps,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
// Parse batch results
|
|
502
|
+
let batchResults: BatchResultItem[];
|
|
503
|
+
try {
|
|
504
|
+
batchResults = parseBatchExecutionPayload(agentResult.stdout.trim());
|
|
505
|
+
} catch (error) {
|
|
506
|
+
// Fallback: agent may have written results to it_{iteration}_test-batch-results.json
|
|
507
|
+
const fallbackPath = join(
|
|
508
|
+
projectRoot,
|
|
509
|
+
FLOW_REL_DIR,
|
|
510
|
+
`it_${state.current_iteration}_test-batch-results.json`,
|
|
511
|
+
);
|
|
512
|
+
try {
|
|
513
|
+
if (await mergedDeps.existsFn(fallbackPath)) {
|
|
514
|
+
const fallbackRaw = await mergedDeps.readFileFn(fallbackPath, "utf8");
|
|
515
|
+
batchResults = parseBatchExecutionPayload(fallbackRaw.trim());
|
|
516
|
+
} else {
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// Parse failure: mark all as failed
|
|
521
|
+
const summary = error instanceof Error ? error.message : "Unknown batch parsing error.";
|
|
522
|
+
for (const tc of pendingAutomatedTests) {
|
|
523
|
+
const entry = progress.entries.find((e) => e.id === tc.id);
|
|
524
|
+
if (!entry) continue;
|
|
525
|
+
await recordTestResult(
|
|
526
|
+
tc,
|
|
527
|
+
{ status: "invocation_failed", evidence: "", notes: summary },
|
|
528
|
+
agentResult.exitCode,
|
|
529
|
+
batchPrompt,
|
|
530
|
+
agentResult.stdout,
|
|
531
|
+
agentResult.stderr,
|
|
532
|
+
entry,
|
|
533
|
+
artifactsDirName,
|
|
534
|
+
projectRoot,
|
|
535
|
+
executionByTestId,
|
|
536
|
+
executedTestIds,
|
|
537
|
+
writeProgress,
|
|
538
|
+
mergedDeps,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
batchResults = [];
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (batchResults.length > 0) {
|
|
546
|
+
// Build a map from testCaseId to result
|
|
547
|
+
const resultMap = new Map<string, BatchResultItem>();
|
|
548
|
+
for (const item of batchResults) {
|
|
549
|
+
resultMap.set(item.testCaseId, item);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const tc of pendingAutomatedTests) {
|
|
553
|
+
const entry = progress.entries.find((e) => e.id === tc.id);
|
|
554
|
+
if (!entry) continue;
|
|
555
|
+
|
|
556
|
+
const batchItem = resultMap.get(tc.id);
|
|
557
|
+
if (batchItem) {
|
|
558
|
+
// Matched result
|
|
559
|
+
await recordTestResult(
|
|
560
|
+
tc,
|
|
561
|
+
{ status: batchItem.status, evidence: batchItem.evidence, notes: batchItem.notes },
|
|
562
|
+
agentResult.exitCode,
|
|
563
|
+
batchPrompt,
|
|
564
|
+
agentResult.stdout,
|
|
565
|
+
agentResult.stderr,
|
|
566
|
+
entry,
|
|
567
|
+
artifactsDirName,
|
|
568
|
+
projectRoot,
|
|
569
|
+
executionByTestId,
|
|
570
|
+
executedTestIds,
|
|
571
|
+
writeProgress,
|
|
572
|
+
mergedDeps,
|
|
573
|
+
);
|
|
574
|
+
} else {
|
|
575
|
+
// Partial results: unmatched test marked as failed
|
|
576
|
+
await recordTestResult(
|
|
577
|
+
tc,
|
|
578
|
+
{ status: "failed", evidence: "", notes: "No result returned by agent for this test case." },
|
|
579
|
+
agentResult.exitCode,
|
|
580
|
+
batchPrompt,
|
|
581
|
+
agentResult.stdout,
|
|
582
|
+
agentResult.stderr,
|
|
583
|
+
entry,
|
|
584
|
+
artifactsDirName,
|
|
585
|
+
projectRoot,
|
|
586
|
+
executionByTestId,
|
|
587
|
+
executedTestIds,
|
|
588
|
+
writeProgress,
|
|
589
|
+
mergedDeps,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// --- One-by-one user-interactive execution for manual/exploratory tests ---
|
|
598
|
+
const manualTests = testCases.filter((tc) => tc.mode === "exploratory_manual");
|
|
599
|
+
for (const testCase of manualTests) {
|
|
600
|
+
const progressEntry = progress.entries.find((entry) => entry.id === testCase.id);
|
|
601
|
+
if (!progressEntry) {
|
|
602
|
+
throw new Error(`Missing progress entry for test case '${testCase.id}'.`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (progressEntry.status === "passed") {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
progressEntry.status = "in_progress";
|
|
610
|
+
progressEntry.updated_at = new Date().toISOString();
|
|
611
|
+
await writeProgress();
|
|
612
|
+
|
|
613
|
+
const userInput = await mergedDeps.promptManualTestFn(testCase);
|
|
614
|
+
|
|
615
|
+
const attemptNumber = progressEntry.attempt_count + 1;
|
|
616
|
+
const artifactFileName = buildArtifactFileName(testCase.id, attemptNumber);
|
|
617
|
+
const artifactRelativePath = join(FLOW_REL_DIR, artifactsDirName, artifactFileName);
|
|
618
|
+
const artifactAbsolutePath = join(projectRoot, artifactRelativePath);
|
|
619
|
+
|
|
620
|
+
const payload: ExecutionPayload = {
|
|
621
|
+
status: userInput.status,
|
|
622
|
+
evidence: userInput.evidence,
|
|
623
|
+
notes: userInput.notes,
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
progressEntry.attempt_count += 1;
|
|
627
|
+
progressEntry.last_agent_exit_code = null;
|
|
628
|
+
progressEntry.last_error_summary = payload.status === "passed" ? "" : payload.notes;
|
|
629
|
+
progressEntry.status = payload.status === "passed" ? "passed" : "failed";
|
|
630
|
+
progressEntry.updated_at = new Date().toISOString();
|
|
631
|
+
await writeProgress();
|
|
632
|
+
|
|
633
|
+
await mergedDeps.writeFileFn(
|
|
634
|
+
artifactAbsolutePath,
|
|
635
|
+
`${JSON.stringify(
|
|
636
|
+
{
|
|
637
|
+
testCaseId: testCase.id,
|
|
638
|
+
attemptNumber,
|
|
639
|
+
prompt: "manual-user-input",
|
|
640
|
+
agentExitCode: 0,
|
|
641
|
+
stdout: JSON.stringify(userInput),
|
|
642
|
+
stderr: "",
|
|
643
|
+
payload,
|
|
644
|
+
},
|
|
645
|
+
null,
|
|
646
|
+
2,
|
|
647
|
+
)}\n`,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
executedTestIds.push(testCase.id);
|
|
651
|
+
executionByTestId.set(testCase.id, {
|
|
652
|
+
testCaseId: testCase.id,
|
|
653
|
+
description: testCase.description,
|
|
654
|
+
correlatedRequirements: testCase.correlatedRequirements,
|
|
655
|
+
mode: testCase.mode,
|
|
656
|
+
payload,
|
|
657
|
+
passFail: derivePassFail(payload.status),
|
|
658
|
+
agentExitCode: 0,
|
|
659
|
+
artifactReferences: [artifactRelativePath],
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const results: TestExecutionResult[] = testCases.map((testCase) => {
|
|
664
|
+
const progressEntry = progress.entries.find((entry) => entry.id === testCase.id);
|
|
665
|
+
if (!progressEntry) {
|
|
666
|
+
throw new Error(`Missing progress entry for test case '${testCase.id}' after execution.`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const latestExecution = executionByTestId.get(testCase.id);
|
|
670
|
+
if (latestExecution) {
|
|
671
|
+
return latestExecution;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const attemptArtifacts = Array.from({ length: progressEntry.attempt_count }, (_, index) => {
|
|
675
|
+
const attemptNumber = index + 1;
|
|
676
|
+
const artifactFileName = buildArtifactFileName(testCase.id, attemptNumber);
|
|
677
|
+
return join(FLOW_REL_DIR, artifactsDirName, artifactFileName);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
testCaseId: testCase.id,
|
|
682
|
+
description: testCase.description,
|
|
683
|
+
correlatedRequirements: testCase.correlatedRequirements,
|
|
684
|
+
mode: testCase.mode,
|
|
685
|
+
payload: {
|
|
686
|
+
status: progressEntry.status === "passed" ? "passed" : "failed",
|
|
687
|
+
evidence: "",
|
|
688
|
+
notes: progressEntry.last_error_summary,
|
|
689
|
+
},
|
|
690
|
+
passFail: progressEntry.status === "passed" ? "pass" : "fail",
|
|
691
|
+
agentExitCode: progressEntry.last_agent_exit_code ?? 0,
|
|
692
|
+
artifactReferences: attemptArtifacts,
|
|
693
|
+
};
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const report: TestExecutionReport = {
|
|
697
|
+
iteration: state.current_iteration,
|
|
698
|
+
testPlanFile: tpGeneration.file,
|
|
699
|
+
executedTestIds,
|
|
700
|
+
results,
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const outFileName = `it_${state.current_iteration}_test-execution-results.json`;
|
|
704
|
+
const outPath = join(projectRoot, FLOW_REL_DIR, outFileName);
|
|
705
|
+
await mergedDeps.writeFileFn(outPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
706
|
+
const markdownReportFileName = `it_${state.current_iteration}_test-execution-report.md`;
|
|
707
|
+
const markdownReportPath = join(projectRoot, FLOW_REL_DIR, markdownReportFileName);
|
|
708
|
+
await mergedDeps.writeFileFn(markdownReportPath, buildMarkdownReport(report));
|
|
709
|
+
|
|
710
|
+
const hasFailedTests = progress.entries.some((entry) => entry.status === "failed");
|
|
711
|
+
state.phases.prototype.test_execution.status = hasFailedTests ? "failed" : "completed";
|
|
712
|
+
state.phases.prototype.test_execution.file = progressFileName;
|
|
713
|
+
state.last_updated = mergedDeps.nowFn().toISOString();
|
|
714
|
+
state.updated_by = "nvst:execute-test-plan";
|
|
715
|
+
await writeState(projectRoot, state);
|
|
716
|
+
|
|
717
|
+
const passedCount = results.filter((result) => result.payload.status === "passed").length;
|
|
718
|
+
const failedCount = results.length - passedCount;
|
|
719
|
+
console.log(
|
|
720
|
+
`${passedCount}/${results.length} tests passed, ${failedCount} failed. Report: ${join(FLOW_REL_DIR, markdownReportFileName)}`,
|
|
721
|
+
);
|
|
722
|
+
}
|