feat: bundle JUnit XML reporter for Jest, replacing external jest-junit dependency

Ship a zero-dependency jest-reporter.js inside the codeflash runtime package
instead of requiring the external jest-junit npm package. This ensures the
reporter is always available when codeflash is installed, fixing Jest-based
projects (Strapi, Moleculer) that failed because jest-junit wasn't installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sarthak Agarwal 2026-03-02 21:59:41 +05:30
parent 86202d40e5
commit 3c2a2b3694
4 changed files with 510 additions and 6 deletions

View file

@ -42,6 +42,12 @@ def clear_created_config_files() -> None:
_created_config_files.clear()
# The bundled JUnit reporter path, resolved as "codeflash/jest-reporter"
# This is shipped inside the codeflash npm runtime package, so it's always
# available when the codeflash runtime is installed (which is already required).
CODEFLASH_JEST_REPORTER = "codeflash/jest-reporter"
def _detect_bundler_module_resolution(project_root: Path) -> bool:
"""Detect if the project uses moduleResolution: 'bundler' in tsconfig.
@ -698,7 +704,7 @@ def run_jest_behavioral_tests(
"npx",
"jest",
"--reporters=default",
"--reporters=jest-junit",
f"--reporters={CODEFLASH_JEST_REPORTER}",
"--runInBand", # Run tests serially for consistent timing
"--forceExit",
]
@ -732,7 +738,7 @@ def run_jest_behavioral_tests(
jest_env["JEST_JUNIT_OUTPUT_FILE"] = str(result_file_path)
jest_env["JEST_JUNIT_OUTPUT_DIR"] = str(result_file_path.parent)
jest_env["JEST_JUNIT_OUTPUT_NAME"] = result_file_path.name
# Configure jest-junit to use filepath-based classnames for proper parsing
# Configure codeflash jest-reporter to use filepath-based classnames for proper parsing
jest_env["JEST_JUNIT_CLASSNAME"] = "{filepath}"
jest_env["JEST_JUNIT_SUITE_NAME"] = "{filepath}"
jest_env["JEST_JUNIT_ADD_FILE_ATTRIBUTE"] = "true"
@ -797,7 +803,7 @@ def run_jest_behavioral_tests(
except FileNotFoundError:
logger.error("Jest not found. Make sure Jest is installed (npm install jest)")
result = subprocess.CompletedProcess(
args=jest_cmd, returncode=-1, stdout="", stderr="Jest not found. Run: npm install jest jest-junit"
args=jest_cmd, returncode=-1, stdout="", stderr="Jest not found. Run: npm install jest"
)
finally:
wall_clock_ns = time.perf_counter_ns() - start_time_ns
@ -947,7 +953,7 @@ def run_jest_benchmarking_tests(
"npx",
"jest",
"--reporters=default",
"--reporters=jest-junit",
f"--reporters={CODEFLASH_JEST_REPORTER}",
"--runInBand", # Ensure serial execution
"--forceExit",
"--runner=codeflash/loop-runner", # Use custom loop runner for in-process looping
@ -1113,7 +1119,7 @@ def run_jest_line_profile_tests(
"npx",
"jest",
"--reporters=default",
"--reporters=jest-junit",
f"--reporters={CODEFLASH_JEST_REPORTER}",
"--runInBand", # Run tests serially for consistent line profiling
"--forceExit",
]

View file

@ -1,6 +1,6 @@
{
"name": "codeflash",
"version": "0.8.0",
"version": "0.10.0",
"description": "Codeflash - AI-powered code optimization for JavaScript and TypeScript",
"main": "runtime/index.js",
"types": "runtime/index.d.ts",
@ -32,6 +32,10 @@
"./loop-runner": {
"require": "./runtime/loop-runner.js",
"import": "./runtime/loop-runner.js"
},
"./jest-reporter": {
"require": "./runtime/jest-reporter.js",
"import": "./runtime/jest-reporter.js"
}
},
"scripts": {

View file

@ -0,0 +1,204 @@
/**
* Codeflash JUnit XML Reporter for Jest.
*
* Minimal reporter that outputs JUnit XML in the format expected by
* codeflash's Python parser. Replaces the external jest-junit dependency.
*
* Configuration via environment variables (same as jest-junit):
* JEST_JUNIT_OUTPUT_FILE absolute path for the XML file (required)
* JEST_JUNIT_CLASSNAME template for classname ("{filepath}" supported)
* JEST_JUNIT_SUITE_NAME template for suite name ("{filepath}" supported)
* JEST_JUNIT_ADD_FILE_ATTRIBUTE "true" to add file= on <testcase>
* JEST_JUNIT_INCLUDE_CONSOLE_OUTPUT "true" to include console.log in <system-out>
*/
"use strict";
const fs = require("fs");
const path = require("path");
function escapeXml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function escapeXmlContent(str) {
if (!str) return "";
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatTemplate(template, values) {
if (!template) return "";
let result = template;
for (const [key, val] of Object.entries(values)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), val || "");
}
return result;
}
class CodeflashJestReporter {
constructor(globalConfig, _reporterOptions) {
this._globalConfig = globalConfig;
this._outputFile = process.env.JEST_JUNIT_OUTPUT_FILE || "jest-results.xml";
this._classnameTemplate = process.env.JEST_JUNIT_CLASSNAME || "{classname}";
this._suiteNameTemplate = process.env.JEST_JUNIT_SUITE_NAME || "{filepath}";
this._addFileAttribute =
process.env.JEST_JUNIT_ADD_FILE_ATTRIBUTE === "true";
this._includeConsoleOutput =
process.env.JEST_JUNIT_INCLUDE_CONSOLE_OUTPUT === "true";
// Capture buffered console output per test file
this._consoleBuffers = new Map();
}
// Called by Jest when a test suite starts — we just note it
onTestStart(_test) {}
// Called by Jest with console output for a test file
onTestFileResult(_test, testResult, _aggregatedResult) {
if (
this._includeConsoleOutput &&
testResult.console &&
testResult.console.length > 0
) {
const messages = testResult.console
.map((entry) => {
const prefix =
entry.type === "error"
? "console.error"
: entry.type === "warn"
? "console.warn"
: "console.log";
return `${prefix}\n ${entry.message}`;
})
.join("\n\n");
this._consoleBuffers.set(testResult.testFilePath, messages);
}
}
onRunComplete(_testContexts, results) {
const suites = [];
let totalTests = 0;
let totalFailures = 0;
let totalErrors = 0;
let totalTime = 0;
for (const suiteResult of results.testResults) {
const filePath = suiteResult.testFilePath || "";
const relativePath = this._globalConfig.rootDir
? path.relative(this._globalConfig.rootDir, filePath)
: filePath;
const templateVars = {
filepath: filePath,
filename: path.basename(filePath),
classname: relativePath.replace(/\//g, ".").replace(/\.[^.]+$/, ""),
title: "",
displayName: suiteResult.displayName || "",
};
const suiteName = formatTemplate(
this._suiteNameTemplate,
templateVars
);
const testcases = [];
let suiteFailures = 0;
let suiteErrors = 0;
let suiteTime = 0;
for (const testResult of suiteResult.testResults) {
const duration = (testResult.duration || 0) / 1000; // ms → seconds
suiteTime += duration;
const tcTemplateVars = {
...templateVars,
title: testResult.fullName || testResult.title || "",
};
const classname = formatTemplate(
this._classnameTemplate,
tcTemplateVars
);
let tcXml = ` <testcase classname="${escapeXml(classname)}" name="${escapeXml(
testResult.fullName || testResult.title
)}" time="${duration.toFixed(3)}"`;
if (this._addFileAttribute) {
tcXml += ` file="${escapeXml(filePath)}"`;
}
if (
testResult.status === "failed" &&
testResult.failureMessages &&
testResult.failureMessages.length > 0
) {
suiteFailures++;
const failureText = testResult.failureMessages.join("\n");
tcXml += `>\n <failure message="${escapeXml(
failureText.split("\n")[0]
)}"><![CDATA[${failureText}]]></failure>\n </testcase>`;
} else if (testResult.status === "pending") {
tcXml += `>\n <skipped/>\n </testcase>`;
} else {
tcXml += "/>";
}
testcases.push(tcXml);
}
totalTests += suiteResult.testResults.length;
totalFailures += suiteFailures;
totalErrors += suiteErrors;
totalTime += suiteTime;
// Build suite XML
let suiteXml = ` <testsuite name="${escapeXml(suiteName)}" tests="${
suiteResult.testResults.length
}" errors="${suiteErrors}" failures="${suiteFailures}" skipped="${
suiteResult.testResults.filter((t) => t.status === "pending").length
}" timestamp="${new Date().toISOString()}" time="${suiteTime.toFixed(3)}"`;
if (this._addFileAttribute) {
suiteXml += ` file="${escapeXml(filePath)}"`;
}
suiteXml += ">\n";
suiteXml += testcases.join("\n") + "\n";
// Add console output as system-out (at suite level, matching jest-junit format)
if (this._includeConsoleOutput) {
const consoleOutput =
this._consoleBuffers.get(suiteResult.testFilePath) || "";
if (consoleOutput) {
suiteXml += ` <system-out><![CDATA[${consoleOutput}]]></system-out>\n`;
}
}
suiteXml += " </testsuite>";
suites.push(suiteXml);
}
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuites name="jest tests" tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}" time="${totalTime.toFixed(3)}">`,
...suites,
"</testsuites>",
].join("\n");
// Ensure output directory exists
const outputDir = path.dirname(this._outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(this._outputFile, xml, "utf8");
}
}
module.exports = CodeflashJestReporter;

View file

@ -728,3 +728,293 @@ class TestBundlerModuleResolutionFix:
# Verify codeflash configs were NOT created
assert not (tmpdir_path / "jest.codeflash.config.js").exists()
assert not (tmpdir_path / "tsconfig.codeflash.json").exists()
class TestBundledJestReporter:
"""Tests for the bundled codeflash/jest-reporter.
Verifies that:
1. The reporter JS file exists in the runtime package
2. Jest commands reference 'codeflash/jest-reporter' (not jest-junit)
3. The reporter produces valid JUnit XML
4. The CODEFLASH_JEST_REPORTER constant is correct
"""
def test_reporter_js_file_exists(self):
"""The jest-reporter.js file must exist in the runtime directory."""
reporter_path = Path(__file__).resolve().parents[2] / "packages" / "codeflash" / "runtime" / "jest-reporter.js"
assert reporter_path.exists(), f"jest-reporter.js not found at {reporter_path}"
def test_reporter_constant_value(self):
"""CODEFLASH_JEST_REPORTER should be 'codeflash/jest-reporter'."""
from codeflash.languages.javascript.test_runner import CODEFLASH_JEST_REPORTER
assert CODEFLASH_JEST_REPORTER == "codeflash/jest-reporter"
def test_behavioral_command_uses_bundled_reporter(self):
"""run_jest_behavioral_tests should use codeflash/jest-reporter in --reporters flag."""
from codeflash.languages.javascript.test_runner import run_jest_behavioral_tests
from codeflash.models.models import TestFile, TestFiles
from codeflash.models.test_type import TestType
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
(tmpdir_path / "package.json").write_text('{"name": "test"}')
test_dir = tmpdir_path / "test"
test_dir.mkdir()
test_file = test_dir / "test_func.test.js"
test_file.write_text("// test")
mock_test_files = TestFiles(
test_files=[
TestFile(
original_file_path=test_file,
instrumented_behavior_file_path=test_file,
benchmarking_file_path=test_file,
test_type=TestType.GENERATED_REGRESSION,
),
]
)
with patch("subprocess.run") as mock_run:
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
mock_result.returncode = 1
mock_run.return_value = mock_result
try:
run_jest_behavioral_tests(
test_paths=mock_test_files,
test_env={},
cwd=tmpdir_path,
project_root=tmpdir_path,
)
except Exception:
pass
if mock_run.called:
cmd = mock_run.call_args[0][0]
reporter_args = [a for a in cmd if "--reporters=" in a and "jest-reporter" in a]
assert len(reporter_args) == 1, f"Expected exactly one codeflash/jest-reporter flag, got: {reporter_args}"
assert reporter_args[0] == "--reporters=codeflash/jest-reporter"
# Must NOT reference jest-junit
jest_junit_args = [a for a in cmd if "jest-junit" in a]
assert len(jest_junit_args) == 0, f"Should not reference jest-junit: {jest_junit_args}"
def test_benchmarking_command_uses_bundled_reporter(self):
"""run_jest_benchmarking_tests should use codeflash/jest-reporter."""
from codeflash.languages.javascript.test_runner import run_jest_benchmarking_tests
from codeflash.models.models import TestFile, TestFiles
from codeflash.models.test_type import TestType
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
(tmpdir_path / "package.json").write_text('{"name": "test"}')
test_dir = tmpdir_path / "test"
test_dir.mkdir()
test_file = test_dir / "test_func__perf.test.js"
test_file.write_text("// test")
mock_test_files = TestFiles(
test_files=[
TestFile(
original_file_path=test_file,
instrumented_behavior_file_path=test_file,
benchmarking_file_path=test_file,
test_type=TestType.GENERATED_REGRESSION,
),
]
)
with patch("subprocess.run") as mock_run:
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
mock_result.returncode = 1
mock_run.return_value = mock_result
try:
run_jest_benchmarking_tests(
test_paths=mock_test_files,
test_env={},
cwd=tmpdir_path,
project_root=tmpdir_path,
)
except Exception:
pass
if mock_run.called:
cmd = mock_run.call_args[0][0]
reporter_args = [a for a in cmd if "--reporters=codeflash/jest-reporter" in a]
assert len(reporter_args) == 1
def test_line_profile_command_uses_bundled_reporter(self):
"""run_jest_line_profile_tests should use codeflash/jest-reporter."""
from codeflash.languages.javascript.test_runner import run_jest_line_profile_tests
from codeflash.models.models import TestFile, TestFiles
from codeflash.models.test_type import TestType
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
(tmpdir_path / "package.json").write_text('{"name": "test"}')
test_dir = tmpdir_path / "test"
test_dir.mkdir()
test_file = test_dir / "test_func__line.test.js"
test_file.write_text("// test")
mock_test_files = TestFiles(
test_files=[
TestFile(
original_file_path=test_file,
instrumented_behavior_file_path=test_file,
benchmarking_file_path=test_file,
test_type=TestType.GENERATED_REGRESSION,
),
]
)
with patch("subprocess.run") as mock_run:
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
mock_result.returncode = 1
mock_run.return_value = mock_result
try:
run_jest_line_profile_tests(
test_paths=mock_test_files,
test_env={},
cwd=tmpdir_path,
project_root=tmpdir_path,
)
except Exception:
pass
if mock_run.called:
cmd = mock_run.call_args[0][0]
reporter_args = [a for a in cmd if "--reporters=codeflash/jest-reporter" in a]
assert len(reporter_args) == 1
def test_reporter_produces_valid_junit_xml(self):
"""The reporter JS should produce JUnit XML parseable by junitparser."""
import subprocess
reporter_path = Path(__file__).resolve().parents[2] / "packages" / "codeflash" / "runtime" / "jest-reporter.js"
with tempfile.TemporaryDirectory() as tmpdir:
output_file = Path(tmpdir) / "results.xml"
# Create a Node.js script that exercises the reporter with mock data
test_script = Path(tmpdir) / "test_reporter.js"
test_script.write_text(f"""
// Set env vars BEFORE requiring reporter (matches real Jest behavior)
process.env.JEST_JUNIT_OUTPUT_FILE = '{output_file}';
process.env.JEST_JUNIT_CLASSNAME = '{{filepath}}';
process.env.JEST_JUNIT_SUITE_NAME = '{{filepath}}';
process.env.JEST_JUNIT_ADD_FILE_ATTRIBUTE = 'true';
process.env.JEST_JUNIT_INCLUDE_CONSOLE_OUTPUT = 'true';
const Reporter = require('{reporter_path}');
// Mock Jest globalConfig
const globalConfig = {{ rootDir: '/tmp/project' }};
const reporter = new Reporter(globalConfig, {{}});
// Mock test results (matches Jest's aggregatedResults structure)
const results = {{
testResults: [
{{
testFilePath: '/tmp/project/test/math.test.js',
displayName: 'math tests',
console: [{{ type: 'log', message: 'CODEFLASH_START test1' }}],
testResults: [
{{
fullName: 'math > adds numbers',
title: 'adds numbers',
status: 'passed',
duration: 12,
}},
{{
fullName: 'math > handles failure',
title: 'handles failure',
status: 'failed',
duration: 5,
failureMessages: ['Expected 4 but got 5'],
}},
{{
fullName: 'math > skipped test',
title: 'skipped test',
status: 'pending',
duration: 0,
}},
],
}},
],
}};
// Simulate onTestFileResult for console capture
reporter.onTestFileResult(null, results.testResults[0], null);
// Simulate onRunComplete
reporter.onRunComplete([], results);
console.log('OK');
""")
result = subprocess.run(
["node", str(test_script)],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0, f"Reporter script failed: {result.stderr}"
assert output_file.exists(), "Reporter did not create output file"
xml_content = output_file.read_text()
# Verify basic XML structure
assert '<?xml version="1.0"' in xml_content
assert "<testsuites" in xml_content
assert "<testsuite" in xml_content
assert "<testcase" in xml_content
# Verify classname uses filepath template
assert 'classname="/tmp/project/test/math.test.js"' in xml_content
# Verify file attribute is present
assert 'file="/tmp/project/test/math.test.js"' in xml_content
# Verify failure element
assert "<failure" in xml_content
assert "Expected 4 but got 5" in xml_content
# Verify skipped element
assert "<skipped/>" in xml_content
# Verify system-out with console output
assert "<system-out>" in xml_content
assert "CODEFLASH_START" in xml_content
# Verify it's parseable by junitparser (our actual parser)
from junitparser import JUnitXml
parsed = JUnitXml.fromfile(str(output_file))
suites = list(parsed)
assert len(suites) == 1
testcases = list(suites[0])
assert len(testcases) == 3
def test_reporter_export_in_package_json(self):
"""package.json should export codeflash/jest-reporter."""
import json
pkg_path = Path(__file__).resolve().parents[2] / "packages" / "codeflash" / "package.json"
with pkg_path.open() as f:
pkg = json.load(f)
exports = pkg.get("exports", {})
assert "./jest-reporter" in exports, "Missing ./jest-reporter export in package.json"
assert exports["./jest-reporter"]["require"] == "./runtime/jest-reporter.js"