Skip to content

[Hackathon] fix(rerun): JUnit drops undispatched tests and keeps stale reports when no runs are accepted #476

Closed as not planned

Description

@luckyzd

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.json

For 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

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.

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

  1. zeshi-du commented on Oct 20, 2026

    @zeshi-du
    Contributor

    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 --all already appends skipped rows, as your control cases show.

    test rerun is being folded into test 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 in test run once it accepts multiple test ids, please open a new issue. Tracked internally as DEV-2028.

    Closing as not planned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    hackathonCLI hackathon submissions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions