Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/coordination/handlers/test-execution-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,17 +223,19 @@ export function registerTestExecutionHandlers(ctx: TaskHandlerContext): void {
let output: string;
try {
execution = spawnSync('npx', ['vitest', 'run', ...testFiles, '--reporter=json', ...report.args], options);
output = report.read(execution.stdout || '');
output = execution.error || execution.signal
? ''
: report.read(execution.stdout || '') ?? '';
} finally {
report.cleanup();
}
// Preserve the existing Jest fallback when Vitest cannot produce a report.
if (!output.includes('{') && execution.status !== 0) {
if (!output.includes('{') && execution.status !== 0 && !execution.error && !execution.signal) {
runner = 'jest';
execution = spawnSync('npx', ['jest', ...testFiles, '--json'], options);
output = execution.stdout || '';
}
const diagnostics = [execution.error?.message, execution.stderr, output].filter(Boolean).join('\n');
const diagnostics = [execution.error?.message, execution.signal && `Terminated by ${execution.signal}`, execution.stderr, output].filter(Boolean).join('\n');
if (execution.error) {
return err(new TestRunnerExecutionError(`${runner} could not complete: ${diagnostics.slice(0, 4000)}`));
}
Expand Down
49 changes: 46 additions & 3 deletions src/domains/test-execution/services/flaky-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { MemoryBackend } from '../../../kernel/interfaces';
import { TEST_EXECUTION_CONSTANTS, RETRY_CONSTANTS } from '../../constants.js';
import { toError } from '../../../shared/error-utils.js';
import { safeJsonParse } from '../../../shared/safe-json.js';
import { getTestRunnerExecutionError } from '../../../shared/test-runner-verdict.js';
import { createVitestJsonReport, needsVitestJsonReportFile } from '../../../shared/vitest-json-report.js';
import { secureRandom } from '../../../shared/utils/crypto-random.js';

Expand Down Expand Up @@ -526,8 +527,19 @@ export class FlakyDetectorService implements IFlakyTestDetector {
child.on('close', (code) => {
clearTimeout(timeout);
const duration = Date.now() - startTime;
const reportText = report ? report.read(stdout) : stdout;
let reportText: string | undefined;
try {
reportText = report ? report.read(stdout) : stdout;
} catch (error) {
report?.cleanup();
reject(toError(error));
return;
}
report?.cleanup();
if (reportText === undefined) {
reject(new Error(`The current Vitest JSON report is missing for ${file}.`));
return;
}

try {
// Parse the test results from the JSON report (or stdout for other runners)
Expand All @@ -541,8 +553,35 @@ export class FlakyDetectorService implements IFlakyTestDetector {
duration
);

if (report) {
const verdictReport = safeJsonParse(reportText) as {
success?: boolean;
numFailedTestSuites?: number;
numRuntimeErrorTestSuites?: number;
testResults?: Array<{
status?: string;
message?: string;
assertionResults?: Array<{ status?: string }>;
}>;
};
const assertions = verdictReport.testResults?.flatMap(
suite => suite.assertionResults ?? []
) ?? [];
const executionError = getTestRunnerExecutionError(
'vitest', file, code,
{
passed: assertions.filter(test => test.status === 'passed').length,
failed: assertions.filter(test => test.status === 'failed').length,
skipped: assertions.filter(test => test.status === 'skipped' || test.status === 'pending').length,
},
stderr, verdictReport
);
if (executionError) throw executionError;
}

// If parsing fails but we have an exit code, create a single result for the file
if (parsedResults.size === 0) {
if (report) throw new Error(`The current Vitest JSON report has no test results for ${file}.`);
const testId = this.generateTestId(file, 'main');
results.set(testId, [
{
Expand All @@ -565,6 +604,10 @@ export class FlakyDetectorService implements IFlakyTestDetector {

resolve(results);
} catch (parseError) {
if (report) {
reject(toError(parseError));
return;
}
// If we can't parse output but process completed, create result from exit code
const testId = this.generateTestId(file, 'main');
results.set(testId, [
Expand Down Expand Up @@ -610,7 +653,7 @@ export class FlakyDetectorService implements IFlakyTestDetector {
const parsed = safeJsonParse(jsonOutput);
return this.parseVitestJson(parsed, file, runId, runIndex);
}
} catch (error) {
} catch {
// Non-critical: not valid JSON, try other formats
logger.debug('Vitest JSON parse failed:');
}
Expand All @@ -624,7 +667,7 @@ export class FlakyDetectorService implements IFlakyTestDetector {
return this.parseJestJson(parsed, file, runId, runIndex);
}
}
} catch (error) {
} catch {
// Non-critical: not Jest format
logger.debug('Jest JSON parse failed:');
}
Expand Down
36 changes: 21 additions & 15 deletions src/domains/test-execution/services/retry-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ export class RetryHandlerService implements IRetryHandler {
if ('jest' in devDeps) return 'jest';
if ('mocha' in devDeps) return 'mocha';
}
} catch (error) {
} catch {
// Non-critical: package.json read errors during test runner detection
logger.debug('package.json read failed:');
}
Expand Down Expand Up @@ -621,8 +621,15 @@ export class RetryHandlerService implements IRetryHandler {

// Parse result based on exit code and output (Vitest writes the JSON
// report to the --outputFile; read it back rather than trusting stdout).
const result = this.parseTestResult(code, report ? report.read(stdout) : stdout, stderr);
settle(resolve, result);
try {
const reportText = report ? report.read(stdout) : stdout;
if (reportText === undefined) {
throw new Error('The current Vitest JSON report is missing for the retry run.');
}
settle(resolve, this.parseTestResult(code, reportText, stderr));
} catch (error) {
settle(reject, toError(error));
}
});

proc.on('error', (err: Error) => {
Expand All @@ -645,62 +652,61 @@ export class RetryHandlerService implements IRetryHandler {
stdout: string,
stderr: string
): { passed: boolean; error?: string } {
// Exit code 0 typically means all tests passed
if (exitCode === 0) {
return { passed: true };
}

// Try to parse JSON output for more detailed error info
// A passing process and a passing report must agree when JSON is available.
try {
// Vitest JSON output
const vitestMatch = stdout.match(/\{[\s\S]*"testResults"[\s\S]*\}/);
if (vitestMatch) {
const result = safeJsonParse(vitestMatch[0]);
if (result.success === true || result.numFailedTests === 0) {
if (exitCode === 0 && result.success !== false
&& result.numFailedTests === 0 && result.numFailedTestSuites === 0) {
return { passed: true };
}
const failedTest = result.testResults?.[0]?.assertionResults?.find(
(r: { status: string }) => r.status === 'failed'
);
return {
passed: false,
error: failedTest?.failureMessages?.join('\n') ?? `Test failed with exit code ${exitCode}`,
error: failedTest?.failureMessages?.join('\n') || stderr || `Test failed with exit code ${exitCode}`,
};
}

// Jest JSON output
const jestMatch = stdout.match(/\{[\s\S]*"numFailedTests"[\s\S]*\}/);
if (jestMatch) {
const result = safeJsonParse(jestMatch[0]);
if (result.success === true || result.numFailedTests === 0) {
if (exitCode === 0 && result.success !== false
&& result.numFailedTests === 0 && result.numFailedTestSuites === 0) {
return { passed: true };
}
const failedTest = result.testResults?.[0]?.assertionResults?.find(
(r: { status: string }) => r.status === 'failed'
);
return {
passed: false,
error: failedTest?.failureMessages?.join('\n') ?? `Test failed with exit code ${exitCode}`,
error: failedTest?.failureMessages?.join('\n') || stderr || `Test failed with exit code ${exitCode}`,
};
}

// Mocha JSON output
const mochaMatch = stdout.match(/\{[\s\S]*"stats"[\s\S]*"failures"[\s\S]*\}/);
if (mochaMatch) {
const result = safeJsonParse(mochaMatch[0]);
if (result.stats?.failures === 0) {
if (exitCode === 0 && result.stats?.failures === 0) {
return { passed: true };
}
const failure = result.failures?.[0];
return {
passed: false,
error: failure?.err?.message ?? `Test failed with exit code ${exitCode}`,
error: failure?.err?.message || stderr || `Test failed with exit code ${exitCode}`,
};
}
} catch {
// JSON parsing failed, fall back to simple exit code check
}

if (exitCode === 0) return { passed: true };

// Non-zero exit code means failure
const errorOutput = stderr || stdout || `Test failed with exit code ${exitCode}`;
return {
Expand Down
49 changes: 35 additions & 14 deletions src/domains/test-execution/services/test-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ Provide:
maxTokens: this.config.llmMaxTokens,
});
return response.content;
} catch (error) {
} catch {
logger.warn('LLM analysis failed:');
return null;
}
Expand Down Expand Up @@ -519,28 +519,37 @@ Provide:
let stdout = '';
let stderr = '';
let killed = false;
const finish = (result: Result<TestExecutionResult, Error>): void => {
report?.cleanup();
let settled = false;
const finish = (result: Result<TestExecutionResult, Error>, cleanup = true): void => {
if (settled) return;
settled = true;
if (cleanup) report?.cleanup();
resolve(result);
};

// Spawn the test runner process
// Note: shell: false (default) to prevent command injection (CWE-78)
// Arguments are passed as array to avoid shell interpretation
const proc: ChildProcess = spawn(command, args, {
cwd: process.cwd(),
env: {
...process.env,
FORCE_COLOR: '0', // Disable color codes for easier parsing
CI: 'true', // Enable CI mode for consistent output
},
});
let proc: ChildProcess;
try {
proc = spawn(command, args, {
cwd: process.cwd(),
env: {
...process.env,
FORCE_COLOR: '0', // Disable color codes for easier parsing
CI: 'true', // Enable CI mode for consistent output
},
});
} catch (error) {
finish(err(new Error(`Failed to spawn test runner: ${toErrorMessage(error)}. Is '${command}' installed?`)));
return;
}

// Set timeout
const timeoutId = setTimeout(() => {
killed = true;
proc.kill('SIGTERM');
finish(err(new Error(`Test execution timed out after ${timeout}ms for files: ${fileLabel}`)));
finish(err(new Error(`Test execution timed out after ${timeout}ms for files: ${fileLabel}`)), false);
}, timeout);

proc.stdout?.on('data', (data: Buffer) => {
Expand All @@ -555,12 +564,23 @@ Provide:
clearTimeout(timeoutId);

if (killed) {
return; // Already handled by timeout
report?.cleanup();
return; // Timeout result was already returned; child has now closed.
}

// Parse results based on framework. Vitest 5 writes the JSON report to
// a file instead of stdout, so read it back through the report handle.
const reportText = report ? report.read(stdout) : stdout;
let reportText: string | undefined;
try {
reportText = report ? report.read(stdout) : stdout;
} catch (error) {
finish(err(toError(error)));
return;
}
if (reportText === undefined) {
finish(err(new Error(`The current Vitest JSON report is missing for ${fileLabel}.`)));
return;
}
const parseResult = this.parseTestOutput(reportText, stderr, fileLabel, framework, code);

// If no coverage in stdout JSON, try reading from disk
Expand All @@ -580,6 +600,7 @@ Provide:

proc.on('error', (error: Error) => {
clearTimeout(timeoutId);
if (killed) return;
finish(err(new Error(`Failed to spawn test runner: ${error.message}. Is '${command}' installed?`)));
});
});
Expand Down
38 changes: 22 additions & 16 deletions src/shared/vitest-json-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* instead of parsing stdout directly.
*/

import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand All @@ -22,29 +22,35 @@ export interface VitestJsonReport {
/** CLI arguments to append to `vitest run --reporter=json`. */
readonly args: readonly string[];
/**
* Return the text that carries the JSON document: the report file when the
* runner wrote one, otherwise the captured stdout (older runners, or a run
* that died before reporting).
* Read this invocation's report. Missing reports return undefined so callers
* can distinguish them from a completed run; malformed reports throw.
* Stdout is diagnostic text, never a substitute for the requested report.
*/
read(stdout: string): string;
read(stdout: string): string | undefined;
/** Remove the temporary report directory. Safe to call more than once. */
cleanup(): void;
}

/**
* Resolve the JSON document text for a finished Vitest run.
* Exported separately so the precedence rule is unit-testable without spawning.
* Read the report owned by a finished Vitest run. The stdout parameter stays
* for existing callers but is never treated as result evidence.
*/
export function resolveVitestJsonOutput(stdout: string, reportPath: string | undefined): string {
if (reportPath && existsSync(reportPath)) {
try {
const content = readFileSync(reportPath, 'utf-8');
if (content.trim().length > 0) return content;
} catch {
// Unreadable report: fall back to stdout below.
}
export function resolveVitestJsonOutput(_stdout: string, reportPath: string | undefined): string | undefined {
if (!reportPath) return undefined;
let content: string;
try {
content = readFileSync(reportPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw new Error('Could not read the current Vitest JSON report.');
}
return stdout;
try {
const parsed = JSON.parse(content);
if (!parsed || !Array.isArray(parsed.testResults)) throw new Error('Invalid report');
} catch {
throw new Error('The current Vitest JSON report is malformed.');
}
return content;
}

/**
Expand Down
Loading
Loading