181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
|
import { execSync } from "node:child_process";
|
|
|
|
export interface TesterResult {
|
|
success: boolean;
|
|
integrationTestsFound: number;
|
|
integrationTestsPassed: number;
|
|
e2eTestsFound: number;
|
|
e2eTestsPassed: number;
|
|
overallPassed: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export class TesterAgent extends BaseAgent {
|
|
readonly name = "tester";
|
|
readonly description = "Runs integration, e2e, functional tests. Validates non-unit test coverage.";
|
|
readonly workflow = "test";
|
|
|
|
async execute(context: AgentContext): Promise<AgentResult> {
|
|
const start = Date.now();
|
|
this.log("Running automated tests...");
|
|
|
|
if (context.backend) {
|
|
const result = await this.executeViaBackend(
|
|
context,
|
|
`Run integration, e2e, and functional tests for phase ${context.phase}. Specification: ${context.specification}. Detect *.integration.test.ts, *.e2e.test.ts, *.functional.test.ts files. Run npm test. Parse output for pass/fail counts per category. Report structured TesterResult. Do NOT write any test files — only detect and run existing ones.`
|
|
);
|
|
return { ...result, duration_ms: Date.now() - start };
|
|
}
|
|
|
|
const result = await this.runMechanicalTests(context);
|
|
const output = JSON.stringify(result, null, 2);
|
|
|
|
return {
|
|
success: result.success,
|
|
output,
|
|
artifacts_created: [],
|
|
decisions: 0,
|
|
escalations: result.overallPassed ? 0 : 1,
|
|
duration_ms: Date.now() - start,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
private async runMechanicalTests(context: AgentContext): Promise<TesterResult> {
|
|
try {
|
|
const srcDir = path.join(context.project_path, "src");
|
|
const integrationFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.integration\.test\.ts$/) : [];
|
|
const e2eFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.e2e\.test\.ts$/) : [];
|
|
const functionalFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.functional\.test\.ts$/) : [];
|
|
|
|
const integrationTestsFound = integrationFiles.length;
|
|
const e2eTestsFound = e2eFiles.length + functionalFiles.length;
|
|
|
|
let overallPassed = false;
|
|
let integrationTestsPassed = 0;
|
|
let e2eTestsPassed = 0;
|
|
|
|
try {
|
|
const testOutput = execSync("npm test 2>&1", {
|
|
cwd: context.project_path,
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
timeout: 120000,
|
|
});
|
|
overallPassed = true;
|
|
|
|
const passCounts = this.parseTestOutput(testOutput);
|
|
integrationTestsPassed = integrationTestsFound > 0 ? integrationTestsFound : 0;
|
|
e2eTestsPassed = e2eTestsFound > 0 ? e2eTestsFound : 0;
|
|
|
|
if (integrationTestsFound > 0) {
|
|
integrationTestsPassed = this.estimateCategoryPassed(testOutput, "integration");
|
|
}
|
|
if (e2eTestsFound > 0) {
|
|
e2eTestsPassed = this.estimateCategoryPassed(testOutput, "e2e");
|
|
}
|
|
} catch (err) {
|
|
const output = err instanceof Error && "stdout" in err
|
|
? (err as unknown as { stdout: string }).stdout || ""
|
|
: "";
|
|
const stderr = err instanceof Error && "stderr" in err
|
|
? (err as unknown as { stderr: string }).stderr || ""
|
|
: "";
|
|
|
|
const combined = `${output}\n${stderr}`;
|
|
overallPassed = false;
|
|
|
|
const passCounts = this.parseTestOutput(combined);
|
|
|
|
if (integrationTestsFound > 0) {
|
|
integrationTestsPassed = this.estimateCategoryPassed(combined, "integration");
|
|
}
|
|
if (e2eTestsFound > 0) {
|
|
e2eTestsPassed = this.estimateCategoryPassed(combined, "e2e");
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
integrationTestsFound,
|
|
integrationTestsPassed,
|
|
e2eTestsFound,
|
|
e2eTestsPassed,
|
|
overallPassed: false,
|
|
error: `npm test failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: overallPassed,
|
|
integrationTestsFound,
|
|
integrationTestsPassed,
|
|
e2eTestsFound,
|
|
e2eTestsPassed,
|
|
overallPassed,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
integrationTestsFound: 0,
|
|
integrationTestsPassed: 0,
|
|
e2eTestsFound: 0,
|
|
e2eTestsPassed: 0,
|
|
overallPassed: false,
|
|
error: `Test execution failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
private findTestFiles(dir: string, pattern: RegExp): string[] {
|
|
const files: string[] = [];
|
|
if (!fs.existsSync(dir)) return files;
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
files.push(...this.findTestFiles(fullPath, pattern));
|
|
} else if (pattern.test(entry.name)) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
private parseTestOutput(output: string): { total: number; passed: number; failed: number } {
|
|
const jestSummary = output.match(/Tests:\s+(\d+)\s+passed(?:,\s+(\d+)\s+failed)?/);
|
|
if (jestSummary) {
|
|
const passed = parseInt(jestSummary[1], 10) || 0;
|
|
const failed = parseInt(jestSummary[2], 10) || 0;
|
|
return { total: passed + failed, passed, failed };
|
|
}
|
|
|
|
const jestAlt = output.match(/(\d+)\s+passing/);
|
|
const jestAltFail = output.match(/(\d+)\s+failing/);
|
|
if (jestAlt) {
|
|
const passed = parseInt(jestAlt[1], 10) || 0;
|
|
const failed = jestAltFail ? parseInt(jestAltFail[1], 10) || 0 : 0;
|
|
return { total: passed + failed, passed, failed };
|
|
}
|
|
|
|
return { total: 0, passed: 0, failed: 0 };
|
|
}
|
|
|
|
private estimateCategoryPassed(output: string, category: string): number {
|
|
const categoryPattern = category === "integration"
|
|
? /\.integration\.test\.ts/g
|
|
: /\.e2e\.test\.ts|\.functional\.test\.ts/g;
|
|
|
|
const mentions = (output.match(categoryPattern) || []).length;
|
|
if (mentions > 0) {
|
|
const failPattern = /FAIL|failed|error/i;
|
|
const lines = output.split("\n").filter(l => categoryPattern.test(l));
|
|
const failed = lines.filter(l => failPattern.test(l)).length;
|
|
return Math.max(mentions - failed, 0);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
} |