import assert from 'node:assert/strict'; import test from 'node:test'; import { classifyTestResult, parseCases } from '../src/adapters/testing/test-results.js'; import { runnerCommand, runnerScript } from '../src/adapters/testing/docker-runner.js'; import { XML_MARKER } from '../src/adapters/testing/language-reports.js'; import { runnerSchema, testCommands } from '../src/domain/runner-config.js'; import { applyChanges, validatePlan } from '../src/domain/repair.js'; import { answer } from './helpers.js'; const xml = (children: string, attributes = 'tests="1"') => `${children}`; const caseXml = ''; const files = new Map([['packages/api/tests/test_api.py', 'def test_negative(): pass']]); const processResult = (stdout: string, code = 0) => ({ stdout, code, stderr: '', timedOut: false }); test('pytest XML preserves repository paths, identities and failed evidence', () => { const passing = classifyTestResult(processResult(XML_MARKER + xml(caseXml)), 'pytest', 1, '/tmp/work', 'packages/api', files); assert.equal(passing.status, 'passed'); assert.equal(passing.cases[0]?.file, 'packages/api/tests/test_api.py'); const failing = xml(caseXml.replace('/>', '>assertion evidence'), 'tests="1" failures="1"'); const failed = classifyTestResult(processResult(failing, 1), 'pytest', 1, '/tmp/work', 'packages/api', files); assert.equal(failed.status, 'failed'); assert.ok(failed.cases[0]?.fingerprint); assert.equal(failed.cases[0]?.id, passing.cases[0]?.id); }); test('JUnit rejects malformed, external entity, inconsistent and ambiguous evidence', () => { const invalid = ['', ']>', xml(caseXml, 'tests="2"'), xml(caseXml, 'tests="1" failures="1"'), xml(caseXml, 'tests="1" errors="NaN"'), xml(caseXml.replace('tests/test_api.py', '../../../outside.py')), xml(caseXml + caseXml, 'tests="2"'), xml(caseXml.replace('/>', '>'))]; for (const content of invalid) assert.equal(classifyTestResult(processResult(content), 'pytest', 1, '/tmp/work', 'packages/api', files).status, 'error'); assert.equal(classifyTestResult(processResult(''), 'pytest', 1).status, 'not_run'); assert.equal(classifyTestResult(processResult(xml(caseXml.replace('/>', '>'))), 'pytest', 1).status, 'not_run'); }); test('Java classname mapping supports multiple JUnit reports and refuses duplicate source mappings', () => { const source = new Map([['packages/java/src/test/java/com/example/ApiTest.java', 'class ApiTest {}']]); const document = xml(''); const result = classifyTestResult(processResult(XML_MARKER + document), 'junit', 1, '/tmp/work', 'packages/java', source); assert.equal(result.status, 'passed'); assert.equal(result.cases[0]?.file, 'packages/java/src/test/java/com/example/ApiTest.java'); assert.equal(parseCases(XML_MARKER + document + XML_MARKER + document.replace('name="negative"', 'name="positive"'), 'junit', '/tmp/work', 'packages/java', source).cases.length, 2); source.set('other/com/example/ApiTest.java', 'class ApiTest {}'); assert.equal(classifyTestResult(processResult(document), 'junit', 1, '/tmp/work', '', source).status, 'error'); }); const goFiles = new Map([['go.mod', 'module example.com/app\n'], ['api/a_test.go', 'package api\nfunc TestNegative(t *testing.T) {}']]); const goEvents = (failed = false) => [ { Action: 'start', Package: 'example.com/app/api' }, { Action: 'run', Package: 'example.com/app/api', Test: 'TestNegative' }, { Action: 'output', Package: 'example.com/app/api', Test: 'TestNegative', Output: 'expected validation error\n' }, { Action: failed ? 'fail' : 'pass', Package: 'example.com/app/api', Test: 'TestNegative', Elapsed: 0.1 }, { Action: failed ? 'fail' : 'pass', Package: 'example.com/app/api', Elapsed: 0.1 } ].map(e => JSON.stringify(e)).join('\n'); test('Go JSON uses module/package plus source function to locate stable test identities', () => { const green = classifyTestResult(processResult(goEvents()), 'go', 1, '/tmp/work', 'api', goFiles); const red = classifyTestResult(processResult(goEvents(true), 1), 'go', 1, '/tmp/work', 'api', goFiles); assert.equal(green.status, 'passed'); assert.equal(green.cases[0]?.file, 'api/a_test.go'); assert.equal(red.status, 'failed'); assert.equal(red.cases[0]?.id, green.cases[0]?.id); assert.ok(red.cases[0]?.fingerprint); }); test('Go compilation failures, truncated streams, duplicate and undiscoverable cases cannot pass', () => { for (const content of [goEvents().split('\n').slice(0, 3).join('\n'), goEvents() + '\n' + goEvents(), JSON.stringify({ Action: 'fail', Package: 'example.com/app/api', FailedBuild: 'example.com/app/api' }), goEvents().replaceAll('TestNegative', 'TestMissing'), goEvents(true).replace(/"fail","Package":"example.com\/app\/api","Elapsed"/, '"pass","Package":"example.com/app/api","Elapsed"')]) { assert.equal(classifyTestResult(processResult(content), 'go', 1, '/tmp/work', '', goFiles).status, 'error'); } }); test('language commands own report flags, disable Go cache and collect fresh XML without shell interpolation', () => { const go = testCommands(runnerSchema.parse({ reporter: 'go', command: ['go', 'test', './...'] }))[0]!; assert.deepEqual(runnerCommand(go), ['go', 'test', '-json', '-count=1', './...']); const pytest = testCommands(runnerSchema.parse({ reporter: 'pytest', command: ['python', '-m', 'pytest'] }))[0]!; assert.ok(runnerCommand(pytest).includes('--junitxml=/tmp/repopilot.xml')); assert.match(runnerScript(pytest), /rm -f/); assert.match(runnerScript(pytest), /"\$@" >&2/); assert.equal(runnerSchema.safeParse({ reporter: 'junit', command: ['mvn', 'test'] }).success, false); assert.equal(runnerSchema.safeParse({ reporter: 'go', command: ['go', 'test', '-count=0'] }).success, false); assert.equal(runnerSchema.safeParse({ reporter: 'junit', command: ['mvn', 'test'], reportFiles: ['../outside.xml'] }).success, false); const junit = testCommands(runnerSchema.parse({ reporter: 'junit', command: ['mvn', 'test'], reportDirectory: 'target/surefire-reports' }))[0]!; assert.ok(runnerScript(junit).includes("'target/surefire-reports'/*.xml")); assert.equal(runnerSchema.safeParse({ reporter: 'junit', command: ['mvn', 'test'], reportDirectory: 'target', reportFiles: ['x.xml'] }).success, false); }); test('generated Python, Go and Java tests are accepted while disabled cases remain blocked', () => { for (const path of ['conftest.py', 'setup.py', 'requirements-dev.txt', 'pom.xml', 'build.gradle.kts']) { assert.throws(() => applyChanges(new Map([[path, 'original']]), [{ path, content: 'weaken tests' }]), /protected/); } for (const [path, content, disabled] of [['tests/test_api.py', 'def test_api(): assert True', '@pytest.mark.skip'], ['api_test.go', 'func TestApi(t *testing.T) {}', 't.Skip("later")'], ['src/test/java/ApiTest.java', 'class ApiTest {}', '@Disabled']] as const) { const plan = { ...answer([{ path, content }]), scenarios: [{ name: 'case', requirement: 'behavior', testFile: path }] }; assert.equal(validatePlan(plan, new Map()).tests[0]?.path, path); assert.throws(() => validatePlan({ ...plan, changes: [{ path, content: content + '\n' + disabled }] }, new Map()), /disabled/); } });