mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
- Add ISessionStorage interface and RedisSessionStorage implementation - Add ContextManager for token budget and compression - Add StartSession, HandleMessage, UndoChange use cases - Update CHANGELOG and TODO documentation - 88 new tests (1174 total), 97.73% coverage
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import { exec } from "node:child_process"
|
|
import { promisify } from "node:util"
|
|
import * as path from "node:path"
|
|
import * as fs from "node:fs/promises"
|
|
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
|
import {
|
|
createErrorResult,
|
|
createSuccessResult,
|
|
type ToolResult,
|
|
} from "../../../domain/value-objects/ToolResult.js"
|
|
|
|
const execAsync = promisify(exec)
|
|
|
|
/**
|
|
* Supported test runners.
|
|
*/
|
|
export type TestRunner = "vitest" | "jest" | "mocha" | "npm"
|
|
|
|
/**
|
|
* Result data from run_tests tool.
|
|
*/
|
|
export interface RunTestsResult {
|
|
/** Test runner that was used */
|
|
runner: TestRunner
|
|
/** Command that was executed */
|
|
command: string
|
|
/** Whether all tests passed */
|
|
passed: boolean
|
|
/** Exit code */
|
|
exitCode: number
|
|
/** Standard output */
|
|
stdout: string
|
|
/** Standard error output */
|
|
stderr: string
|
|
/** Execution time in milliseconds */
|
|
durationMs: number
|
|
}
|
|
|
|
/**
|
|
* Default test timeout in milliseconds (5 minutes).
|
|
*/
|
|
const DEFAULT_TIMEOUT = 300000
|
|
|
|
/**
|
|
* Maximum output size in characters.
|
|
*/
|
|
const MAX_OUTPUT_SIZE = 200000
|
|
|
|
/**
|
|
* Tool for running project tests.
|
|
* Auto-detects test runner (vitest, jest, mocha, npm test).
|
|
*/
|
|
export class RunTestsTool implements ITool {
|
|
readonly name = "run_tests"
|
|
readonly description =
|
|
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
|
|
"Returns test results summary."
|
|
readonly parameters: ToolParameterSchema[] = [
|
|
{
|
|
name: "path",
|
|
type: "string",
|
|
description: "Run tests for specific file or directory",
|
|
required: false,
|
|
},
|
|
{
|
|
name: "filter",
|
|
type: "string",
|
|
description: "Filter tests by name pattern",
|
|
required: false,
|
|
},
|
|
{
|
|
name: "watch",
|
|
type: "boolean",
|
|
description: "Run in watch mode (default: false)",
|
|
required: false,
|
|
},
|
|
]
|
|
readonly requiresConfirmation = false
|
|
readonly category = "run" as const
|
|
|
|
private readonly execFn: typeof execAsync
|
|
private readonly fsAccess: typeof fs.access
|
|
private readonly fsReadFile: typeof fs.readFile
|
|
|
|
constructor(
|
|
execFn?: typeof execAsync,
|
|
fsAccess?: typeof fs.access,
|
|
fsReadFile?: typeof fs.readFile,
|
|
) {
|
|
this.execFn = execFn ?? execAsync
|
|
this.fsAccess = fsAccess ?? fs.access
|
|
this.fsReadFile = fsReadFile ?? fs.readFile
|
|
}
|
|
|
|
validateParams(params: Record<string, unknown>): string | null {
|
|
if (params.path !== undefined && typeof params.path !== "string") {
|
|
return "Parameter 'path' must be a string"
|
|
}
|
|
if (params.filter !== undefined && typeof params.filter !== "string") {
|
|
return "Parameter 'filter' must be a string"
|
|
}
|
|
if (params.watch !== undefined && typeof params.watch !== "boolean") {
|
|
return "Parameter 'watch' must be a boolean"
|
|
}
|
|
return null
|
|
}
|
|
|
|
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
|
const startTime = Date.now()
|
|
const callId = `${this.name}-${String(startTime)}`
|
|
|
|
const testPath = params.path as string | undefined
|
|
const filter = params.filter as string | undefined
|
|
const watch = (params.watch as boolean) ?? false
|
|
|
|
try {
|
|
const runner = await this.detectTestRunner(ctx.projectRoot)
|
|
|
|
if (!runner) {
|
|
return createErrorResult(
|
|
callId,
|
|
"No test runner detected. Ensure vitest, jest, or mocha is installed, or 'test' script exists in package.json.",
|
|
Date.now() - startTime,
|
|
)
|
|
}
|
|
|
|
const command = this.buildCommand(runner, testPath, filter, watch)
|
|
const execStartTime = Date.now()
|
|
|
|
try {
|
|
const { stdout, stderr } = await this.execFn(command, {
|
|
cwd: ctx.projectRoot,
|
|
timeout: DEFAULT_TIMEOUT,
|
|
maxBuffer: MAX_OUTPUT_SIZE,
|
|
env: { ...process.env, FORCE_COLOR: "0", CI: "true" },
|
|
})
|
|
|
|
const durationMs = Date.now() - execStartTime
|
|
|
|
const result: RunTestsResult = {
|
|
runner,
|
|
command,
|
|
passed: true,
|
|
exitCode: 0,
|
|
stdout: this.truncateOutput(stdout),
|
|
stderr: this.truncateOutput(stderr),
|
|
durationMs,
|
|
}
|
|
|
|
return createSuccessResult(callId, result, Date.now() - startTime)
|
|
} catch (error) {
|
|
return this.handleExecError(
|
|
{ callId, runner, command, startTime },
|
|
error,
|
|
execStartTime,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return createErrorResult(callId, message, Date.now() - startTime)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect which test runner is available in the project.
|
|
*/
|
|
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
|
|
const configRunner = await this.detectByConfigFile(projectRoot)
|
|
if (configRunner) {
|
|
return configRunner
|
|
}
|
|
|
|
return this.detectByPackageJson(projectRoot)
|
|
}
|
|
|
|
private async detectByConfigFile(projectRoot: string): Promise<TestRunner | null> {
|
|
const configFiles: { files: string[]; runner: TestRunner }[] = [
|
|
{
|
|
files: ["vitest.config.ts", "vitest.config.js", "vitest.config.mts"],
|
|
runner: "vitest",
|
|
},
|
|
{
|
|
files: ["jest.config.js", "jest.config.ts", "jest.config.json"],
|
|
runner: "jest",
|
|
},
|
|
]
|
|
|
|
for (const { files, runner } of configFiles) {
|
|
for (const file of files) {
|
|
if (await this.hasFile(projectRoot, file)) {
|
|
return runner
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private async detectByPackageJson(projectRoot: string): Promise<TestRunner | null> {
|
|
const packageJsonPath = path.join(projectRoot, "package.json")
|
|
try {
|
|
const content = await this.fsReadFile(packageJsonPath, "utf-8")
|
|
const pkg = JSON.parse(content) as {
|
|
scripts?: Record<string, string>
|
|
devDependencies?: Record<string, string>
|
|
dependencies?: Record<string, string>
|
|
}
|
|
|
|
const deps = { ...pkg.devDependencies, ...pkg.dependencies }
|
|
if (deps.vitest) {
|
|
return "vitest"
|
|
}
|
|
if (deps.jest) {
|
|
return "jest"
|
|
}
|
|
if (deps.mocha) {
|
|
return "mocha"
|
|
}
|
|
if (pkg.scripts?.test) {
|
|
return "npm"
|
|
}
|
|
} catch {
|
|
// package.json doesn't exist or is invalid
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Build the test command based on runner and options.
|
|
*/
|
|
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
|
|
const builders: Record<TestRunner, () => string[]> = {
|
|
vitest: () => this.buildVitestCommand(testPath, filter, watch),
|
|
jest: () => this.buildJestCommand(testPath, filter, watch),
|
|
mocha: () => this.buildMochaCommand(testPath, filter, watch),
|
|
npm: () => this.buildNpmCommand(testPath, filter),
|
|
}
|
|
return builders[runner]().join(" ")
|
|
}
|
|
|
|
private buildVitestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
|
|
const parts = ["npx vitest"]
|
|
if (!watch) {
|
|
parts.push("run")
|
|
}
|
|
if (testPath) {
|
|
parts.push(testPath)
|
|
}
|
|
if (filter) {
|
|
parts.push("-t", `"${filter}"`)
|
|
}
|
|
return parts
|
|
}
|
|
|
|
private buildJestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
|
|
const parts = ["npx jest"]
|
|
if (testPath) {
|
|
parts.push(testPath)
|
|
}
|
|
if (filter) {
|
|
parts.push("-t", `"${filter}"`)
|
|
}
|
|
if (watch) {
|
|
parts.push("--watch")
|
|
}
|
|
return parts
|
|
}
|
|
|
|
private buildMochaCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
|
|
const parts = ["npx mocha"]
|
|
if (testPath) {
|
|
parts.push(testPath)
|
|
}
|
|
if (filter) {
|
|
parts.push("--grep", `"${filter}"`)
|
|
}
|
|
if (watch) {
|
|
parts.push("--watch")
|
|
}
|
|
return parts
|
|
}
|
|
|
|
private buildNpmCommand(testPath?: string, filter?: string): string[] {
|
|
const parts = ["npm test"]
|
|
if (testPath || filter) {
|
|
parts.push("--")
|
|
if (testPath) {
|
|
parts.push(testPath)
|
|
}
|
|
if (filter) {
|
|
parts.push(`"${filter}"`)
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
/**
|
|
* Check if a file exists.
|
|
*/
|
|
private async hasFile(projectRoot: string, filename: string): Promise<boolean> {
|
|
try {
|
|
await this.fsAccess(path.join(projectRoot, filename))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle exec errors and return appropriate result.
|
|
*/
|
|
private handleExecError(
|
|
ctx: { callId: string; runner: TestRunner; command: string; startTime: number },
|
|
error: unknown,
|
|
execStartTime: number,
|
|
): ToolResult {
|
|
const { callId, runner, command, startTime } = ctx
|
|
const durationMs = Date.now() - execStartTime
|
|
|
|
if (this.isExecError(error)) {
|
|
const result: RunTestsResult = {
|
|
runner,
|
|
command,
|
|
passed: false,
|
|
exitCode: error.code ?? 1,
|
|
stdout: this.truncateOutput(error.stdout ?? ""),
|
|
stderr: this.truncateOutput(error.stderr ?? error.message),
|
|
durationMs,
|
|
}
|
|
|
|
return createSuccessResult(callId, result, Date.now() - startTime)
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
|
|
return createErrorResult(
|
|
callId,
|
|
`Tests timed out after ${String(DEFAULT_TIMEOUT / 1000)} seconds`,
|
|
Date.now() - startTime,
|
|
)
|
|
}
|
|
return createErrorResult(callId, error.message, Date.now() - startTime)
|
|
}
|
|
|
|
return createErrorResult(callId, String(error), Date.now() - startTime)
|
|
}
|
|
|
|
/**
|
|
* Type guard for exec error.
|
|
*/
|
|
private isExecError(
|
|
error: unknown,
|
|
): error is Error & { code?: number; stdout?: string; stderr?: string } {
|
|
return error instanceof Error && "code" in error
|
|
}
|
|
|
|
/**
|
|
* Truncate output if too large.
|
|
*/
|
|
private truncateOutput(output: string): string {
|
|
if (output.length <= MAX_OUTPUT_SIZE) {
|
|
return output
|
|
}
|
|
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
|
|
}
|
|
}
|