Repository navigation
[Hackathon] fix(rerun): JUnit drops undispatched tests and keeps stale reports when no runs are accepted #476
Description
Verified against (commit SHA or released version)
Public main 988c0355bb05631e9c6c1785e443937ff2a88c0e (checked 2026-10-17), package v0.14.0, Node v22.23.2, macOS. Rebuilt this clean checkout and ran the actual CLI entry point with a fully local fetch fixture. No live test runs or credits were used.
Discord Username / User ID
luckyzd.
What does this improvement do?
Make batch test rerun --wait --report junit account for every requested member, including tests that were not dispatched.
Currently, notFound, unresolved deferred, and conflicts appear in the JSON summary but disappear from JUnit. For example, requesting two tests with one passed and one not replayable produces tests="1" skipped="0", while --summary-file correctly says total: 2, passed: 1, skipped: 1. A CI test-results view consuming JUnit presents complete success for the subset and loses the skipped test's identity.
If no tests are accepted, the command exits 4/6/7 without writing the requested JUnit file at all. When a previous report already occupies that valid writable path, its old passing results remain unchanged and can be collected by an always-run artifact step.
This is an artifact accounting bug. The mixed notFound case may keep its documented exit 0; the report must still include the skipped member. This proposal does not change conflict exit-code policy from #380.
Details / implementation notes
Reproduction
Use two frontend test IDs and a valid writable report destination:
testsprite test rerun test_1 test_2 --project project_fixture --wait \
--timeout 1 --output json --report junit --report-file results.xml \
--summary-file summary.jsonFor the smallest case, the batch endpoint returns a schema-valid response with accepted: [{testId: "test_1", runId: "run_test_1", enqueuedAt: "2026-10-17T00:00:00Z"}], notFound: ["test_2"], empty conflicts/deferred, and closure: {byProject: []}. Polling run_test_1 returns passed.
Observed current JUnit:
<testsuite name="testsprite:project_fixture" tests="1" failures="0" errors="0" skipped="0" time="1">
<testcase classname="project_fixture" name="test_1" testId="test_1" runId="run_test_1" time="1">
</testcase>
</testsuite>There is no testcase for test_2, despite summary.json containing total: 2, passed: 1, skipped: 1 and its skipped row. Expected: two cases, one <skipped> case naming test_2 and the reason.
Executed comparison matrix
All 11 cases below ran through the compiled CLI. HTTP was replaced locally, while argument parsing, response validation, dispatch/poll orchestration, file writing and process exit were real.
| Case | Exit | Machine summary | JUnit actually written |
|---|---|---|---|
| Rerun: both passed (control) | 0 | 2 total / 2 passed / 0 skipped | 2 tests / 0 skipped |
| Rerun: passed + notFound | 0 | 2 / 1 / 1 | 1 test / 0 skipped |
Rerun --all: passed + notFound |
0 | 2 / 1 / 1 | 1 test / 0 skipped |
| Rerun: passed + unresolved conflict | 0 | 2 / 1 / 1 | 1 test / 0 skipped |
| Rerun: passed + unresolved deferred | 7 | 2 / 1 / 1 | 1 test / 0 skipped |
| Rerun: both notFound | 4 | 2 / 0 / 2 | No file |
| Rerun: both conflicts | 6 | 2 / 0 / 2 | No file |
| Rerun: both deferred | 7 | 2 / 0 / 2 | No file |
| Rerun: both notFound, previous report exists | 4 | 2 / 0 / 2 | Old report remains byte-for-byte unchanged |
Fresh test run --all: passed + hard conflict (control) |
6 | 2 / 1 / 1 | 2 tests / 1 skipped |
Fresh test run --all: both hard conflicts (control) |
6 | 2 / 0 / 2 | 2 tests / 2 skipped |
The deferred cases deliberately exhaust a one-second budget; they do not simulate a long production retry interval. The stale-file case seeds a small synthetic prior report with 9 passed tests, then verifies its exact bytes survive the new all-notFound invocation. These are offline reproductions, not evidence that a live account returned those outcomes.
Cause and focused fix scope
- Rerun report call passes only
rerunResults, so undispatched members cannot reach the XML builder. - Zero-accepted branch emits JSON and machine-summary artifacts, then throws/returns before reaching the JUnit writer.
- Fresh batch already appends skipped JUnit rows and writes a skipped report on its zero-pollable conflict/deferred branch. The existing skippedJUnitResultsFromSummary helper already handles deduplication.
Bring rerun's artifact accounting into parity: use the final post-retry deferred/conflicts/notFound state to add skipped rows, and write a current report before the zero-accepted exits. Preserve stdout, existing exit-code precedence, real run timing, and atomic writes. Cover explicit IDs and --all, mixed/all-skipped cases, replacement of an older report, and post-retry transitions. No dependency or backend change is needed.
Duplicate check
Searched open and closed Issues/PRs for JUnit + rerun / skipped / deferred / notFound / missing / stale / zero, and read related reports and PR #96 review comments.
- [Hackathon] fix(rerun): batch
--waitexits 0 when refused conflicts leave the rerun incomplete #380 changes the mixed-conflict exit gate. This report includesnotFoundwith its intentionally successful exit and deferred/all-notFound with already nonzero exits; the JUnit omission remains independently fixable without changing that gate. - [Hackathon] Validate --report-file and --summary-file paths before dispatching tests #455 concerns invalid report paths and pre-dispatch validation. All destinations here have existing writable parents; the writer works in control cases.
- testlist run exits 0 (green CI) on an empty list — every other zero-dispatch path exits 5 #458 concerns an actually empty test list exiting 0. Here two tests were requested and are present in the machine summary; zero-accepted reruns already exit nonzero.
- [Hackathon] JUnit XML report export for batch test runs (CI integration) #95 / PR feat(test): JUnit XML report export for batch --wait runs #96 introduced reporting; their reviewed findings do not cover these skipped-member and zero-accepted omissions.
- [Hackathon] fix(rerun): later chunk failure discards already-accepted run IDs (51 tests, 50 hidden runs) #405 / PR fix(rerun): preserve acknowledged runs after a later dispatch failure #410 concern HTTP failure during chunk dispatch and preserving acknowledged runs. Every dispatch response here completes successfully with a valid body.
Build passed. Existing src/commands/test.rerun.spec.ts and src/lib/junit-report.test.ts also passed: 159 tests, 2 files, 6.50 seconds. Passing existing tests does not mean this omission is fixed; the separate reproduction asserts and records the current incorrect behavior.
This is an issue-first Season 4 CLI Improvement Bonus submission. I will wait for maintainer acceptance and assignment before implementing a fix or opening a PR. AI disclosure: Codex assisted with source review, duplicate checking, and the executed local reproduction.
Self-contained offline reproduction script (11 cases)
Save as repro-rerun-junit.mjs. In the repository run npm run build, then run node repro-rerun-junit.mjs /absolute/path/to/testsprite-cli with Node 22. The script prints the XML, machine summary, exit status and request trace for each case. It uses a fake API key, intercepts all fetch requests and cleans its temporary directory.
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
// The real compiled CLI runs with fetch fully replaced by an offline fixture.
// No live account, service, test execution, or credits are involved.
const exec = promisify(execFile);
const root = resolve(process.argv[2] ?? 'work/testsprite-cli');
const temp = await mkdtemp(join(tmpdir(), 'testsprite-rerun-junit-'));
const preload = join(temp, 'fixture.mjs');
await writeFile(preload, `
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
const scenario = JSON.parse(process.env.REPRO_SCENARIO);
const trace = [];
process.on('exit', () => writeFileSync(process.env.REPRO_TRACE, JSON.stringify(trace)));
const json = body => new Response(JSON.stringify(body), {status:200,headers:{'content-type':'application/json'}});
const ids = ['test_1','test_2'];
const rows = ids.map(id => ({id,name:id,projectId:'project_fixture',type:'frontend',
createdFrom:'cli',createdAt:'2026-10-16T00:00:00Z',updatedAt:'2026-10-17T00:00:00Z',status:'passed'}));
globalThis.fetch = async (input, init = {}) => {
const url = new URL(typeof input === 'string' ? input : input.url ?? input.href);
const method = init.method ?? 'GET';
const body = init.body ? JSON.parse(String(init.body)) : undefined;
trace.push({method,path:url.pathname,...(body?{body}:{})});
assert.equal(url.hostname,'mock.example.com','unexpected network destination');
const path = url.pathname.replace('/api/cli/v1','');
if (method === 'GET' && path === '/tests') return json({items:rows,nextToken:null});
if (method === 'GET' && path === '/projects/project_fixture/env') return json({environments:[]});
if (method === 'GET' && path.startsWith('/tests/')) {
const row = rows.find(row => path === '/tests/'+row.id);
if (row) return json(row);
}
if (method === 'POST' && ['/tests/batch/rerun','/tests/batch/run'].includes(path)) {
const selected = body.testIds ?? ids;
const accepted = selected.filter(id => scenario.allPassed || (!scenario.none && id === 'test_1'))
.map(testId => ({testId,runId:'run_'+testId,enqueuedAt:'2026-10-17T00:00:00Z'}));
const skipped = selected.filter(id => !accepted.some(row => row.testId === id));
return json({accepted,
notFound:scenario.kind === 'notFound' ? skipped : [],
deferred:scenario.kind === 'deferred' ? skipped.map(testId => ({testId,reason:'rate_limited'})) : [],
conflicts:scenario.kind === 'conflicts' ? skipped.map(testId => ({testId,reason:'in_flight'})) : [],
closure:{byProject:[]},skippedFrontend:[],skippedIntegration:[],
});
}
if (method === 'GET' && path.startsWith('/runs/')) {
const runId = path.slice('/runs/'.length);
const testId = runId.slice('run_'.length);
assert.ok(ids.includes(testId),'unexpected run');
return json({runId,testId,status:'passed',projectId:'project_fixture',userId:'fixture_user',source:'cli',
createdAt:'2026-10-17T00:00:00Z',startedAt:'2026-10-17T00:00:01Z',finishedAt:'2026-10-17T00:00:02Z',
codeVersion:'v1',targetUrl:'https://example.com',createdFrom:'rerun:previous',
failedStepIndex:null,failureKind:null,error:null,videoUrl:null,
stepSummary:{total:1,completed:1,passedCount:1,failedCount:0}});
}
throw new Error('Unexpected fixture route: '+method+' '+path);
};
`);
const cases = [
{name:'rerun-all-passed-control',allPassed:true,code:0,xmlTests:2,xmlSkipped:0,summarySkipped:0},
{name:'rerun-partial-notFound',kind:'notFound',code:0,xmlTests:1,xmlSkipped:0,summarySkipped:1},
{name:'rerun-all-selection-partial-notFound',all:true,kind:'notFound',code:0,xmlTests:1,xmlSkipped:0,summarySkipped:1},
{name:'rerun-partial-conflicts',kind:'conflicts',code:0,xmlTests:1,xmlSkipped:0,summarySkipped:1},
{name:'rerun-partial-deferred',kind:'deferred',code:7,xmlTests:1,xmlSkipped:0,summarySkipped:1},
{name:'rerun-none-notFound',kind:'notFound',none:true,code:4,missing:true,summarySkipped:2},
{name:'rerun-none-conflicts',kind:'conflicts',none:true,code:6,missing:true,summarySkipped:2},
{name:'rerun-none-deferred',kind:'deferred',none:true,code:7,missing:true,summarySkipped:2},
{name:'rerun-none-notFound-stale-report',kind:'notFound',none:true,seed:true,code:4,stale:true,summarySkipped:2},
{name:'fresh-partial-conflicts-control',fresh:true,kind:'conflicts',code:6,xmlTests:2,xmlSkipped:1,summarySkipped:1},
{name:'fresh-none-conflicts-control',fresh:true,kind:'conflicts',none:true,code:6,xmlTests:2,xmlSkipped:2,summarySkipped:2},
];
const results = [];
const staleReport = '<?xml version="1.0"?><testsuites><testsuite name="old-run" tests="9" failures="0" errors="0" skipped="0"/></testsuites>\n';
try {
for (const scenario of cases) {
const traceFile = join(temp,scenario.name+'.trace.json');
const reportFile = join(temp,scenario.name+'.xml');
const summaryFile = join(temp,scenario.name+'.summary.json');
if (scenario.seed) await writeFile(reportFile,staleReport);
const args = ['test',scenario.fresh?'run':'rerun',
...(scenario.fresh||scenario.all?['--all']:['test_1','test_2']),
'--project','project_fixture','--wait','--timeout','1','--output','json',
'--report','junit','--report-file',reportFile,'--summary-file',summaryFile];
let stdout = '', stderr = '', code = 0;
try {
({stdout,stderr} = await exec(process.execPath,['--import',preload,join(root,'dist/index.js'),...args],{
cwd:temp,timeout:15000,
env:{PATH:dirname(process.execPath)+':/usr/bin:/bin',HOME:temp,
TESTSPRITE_API_KEY:'sk-user-offline-fixture',TESTSPRITE_API_URL:'https://mock.example.com',
TESTSPRITE_NO_TELEMETRY:'1',TESTSPRITE_NO_UPDATE_NOTIFIER:'1',
REPRO_SCENARIO:JSON.stringify(scenario),REPRO_TRACE:traceFile},
}));
} catch (err) {
if (typeof err.code !== 'number') throw err;
({stdout,stderr,code} = err);
}
assert.equal(code,scenario.code,scenario.name+' exit; stderr: '+stderr);
const trace = JSON.parse(await readFile(traceFile,'utf8'));
const summary = JSON.parse(await readFile(summaryFile,'utf8'));
const xml = await readFile(reportFile,'utf8').catch(err => {if(err.code==='ENOENT')return null;throw err;});
assert.equal(code,scenario.code,scenario.name+' exit; stderr: '+stderr);
assert.equal(summary.total,2,scenario.name+' summary.total');
assert.equal(summary.skipped,scenario.summarySkipped,scenario.name+' summary.skipped');
if (scenario.missing) assert.equal(xml,null,scenario.name+' missing XML');
else if (scenario.stale) assert.equal(xml,staleReport,scenario.name+' unchanged old XML');
else {
assert.ok(xml.includes('tests="'+scenario.xmlTests+'"'),scenario.name+' XML count: '+xml);
assert.ok(xml.includes('skipped="'+scenario.xmlSkipped+'"'),scenario.name+' XML skipped');
if (!scenario.allPassed && !scenario.fresh) assert.ok(!xml.includes('test_2'),scenario.name+' missing member');
}
results.push({name:scenario.name,exitCode:code,summary,xml,trace,stdout,stderr});
}
console.log(JSON.stringify({node:process.version,platform:process.platform,results},null,2));
} finally { await rm(temp,{recursive:true,force:true}); }Confirmations
- I have searched existing issues and this is not a duplicate.
- I have provided my Discord identity above for reward coordination.
Activity
zeshi-du commentedon Oct 20, 2026
Thanks @luckyzd, the reproduction matrix was clear. We re-checked v0.15.0 and this is rerun-only: the report is built from polled rerun results only, and the zero-accepted conflict and notFound branches throw before any report is written.
test run --allalready appends skipped rows, as your control cases show.test rerunis being folded intotest run, deprecated in an upcoming release and removed in 1.0, so we're not taking changes to rerun-only paths. If you see the same omission intest runonce it accepts multiple test ids, please open a new issue. Tracked internally as DEV-2028.Closing as not planned.