Skip to content

[Hackathon] fix(run): mixed local/remote batches time out completed backend tests #478

Closed as not planned

Description

@luckyzd

Verified against (commit SHA or released version)

988c035 (public main rechecked 2026-10-17; package 0.14.0), Node.js v22.23.2, macOS

Discord Username / User ID

luckyzd.

What does this improvement do?

Make mixed local-frontend / remote-backend test run batches read the completed backend verdict instead of always timing out when the backend run row remains non-terminal.

There is a concrete accepted CLI path for this: pass two explicit test IDs, where the frontend test's project has a saved loopback default environment and the backend test's project has a public default environment. The frontend opens an automatic tunnel; the backend is dispatched normally without a tunnel override. Both runs are accepted. However, the local-batch poller omits the backend result fallback that single-test run, ordinary batch run and wait use.

For the backend behavior already covered by this repository's tests (non-terminal GET /runs/<id>, completed verdict at GET /tests/<id>/result), the mixed batch never reads the latter endpoint. A passed backend test becomes a timeout/exit 7; a failed backend test also becomes a timeout/exit 7, with failed: 0, timedOut: 1 in the machine summary. The run ID is preserved but its real verdict is missed. Increasing the timeout only prolongs the wait.

This is a Season 4 hackathon issue-first submission. The evidence is an executed offline reproduction of the real command chain using the repository's supported backend response model, not a claim that I ran a live customer workload. No malformed responses, HTTP failures or mid-run state reclassification are required.

Details / implementation notes

Trigger conditions

  1. fe is a frontend test in local_project; that project's default environment URL is http://127.0.0.1:5173.
  2. be is a backend test in remote_project; that project's default environment URL is https://example.com.
  3. Run both explicit IDs, relying on automatic environment resolution. Do not pass --local, which has a separate explicit-backend rejection path:
testsprite test run fe be --wait --timeout 3 --skip-preflight --output json \
  --summary-file summary.json --report junit --report-file results.xml \
  --report-suite-name mixed-fixture

The short timeout makes the offline demonstration quick. --skip-preflight avoids checking a real local listener in the fixture; it does not change polling or verdict selection.

  1. Both triggers return real-shaped accepted run IDs. The frontend's run card is passed. The backend's run card remains running, while its test result is terminal and explicitly identifies run_be through runIdIfAvailable.

The CLI sends the frontend trigger with targetUrl and tunnelClientId. It sends the backend trigger with {source: "cli"}. Thus the backend is neither rejected nor skipped, and is not being sent through a tunnel intended for a browser.

Actual and expected

Actual final JSON (irrelevant metadata omitted):

{
  "results": [
    {"testId": "fe", "runId": "run_fe", "status": "passed"},
    {"testId": "be", "runId": "run_be", "status": "timeout"}
  ],
  "skipped": [],
  "summary": {"total": 2, "passed": 1, "failed": 0, "timedOut": 1, "notRun": 0}
}

The backend result endpoint is read zero times. Exit is 7 in both passed-result and failed-result cases. The reduced summary also reports timeout; JUnit emits a <failure type="timeout"> instead of the actual verdict.

Expected: use the same run-ID/time-correlated backend fallback as other run paths. With two passed tests, exit 0 and two passes. With frontend passed/backend failed, exit 1 and one failed test. Preserve the existing tunnel ownership/liveness behavior and do not attach the remote backend to the frontend's tunnel.

If this mixed selection is intentionally unsupported, it should instead be rejected before dispatching either test. Currently the CLI sends both trigger requests and accepts both run IDs, then misclassifies the backend after waiting.

Source evidence

Executed controls

All eight scenarios use real Commander parsing, HTTP response validation, command orchestration, polling timers, summary writing and JUnit writing. Only HTTP responses and the tunnel transport are injected. No clock or sleep is mocked; no hosted API or credits are used.

Case Backend result reads Actual exit Actual backend verdict
Backend alone, result passed 1 0 passed
Backend alone, result failed 1 1 failed
Local frontend + backend result passed 0 7 timeout
Local frontend + backend result failed 0 7 timeout
Same mixed passed case, timeout increased from 1 to 3 seconds 0 7 timeout
Same mixed passed case, IDs reversed 0 7 timeout
Two local frontend tests n/a 0 both passed
Mixed batch, backend run card itself terminal 0 0 passed

The 3-second case took 3.005 seconds; the terminal backend result was available throughout but never requested. Existing test.local-batch.spec.ts and test.run.spec.ts passed: 272 tests, 2 files, 24.93 seconds. Product source was not modified.

Duplicate check and scope

Searched open/closed Issues and PRs for runTestRunLocalBatch, local batch, mixed+tunnel, backend+fallback, backend+batch+timeout, and tunnel+verdict. No matching mixed-batch verdict report was found on 2026-10-17.

I will wait for maintainer acceptance and assignment before implementing a fix or opening a PR. AI disclosure: Codex assisted with source review, executed reproduction and duplicate checking.

Self-contained offline reproduction

Build the pinned checkout using npm ci && npm run build. Save the following as repro.mjs outside the checkout and run node /path/to/repro.mjs /absolute/path/to/testsprite-cli. It takes about six seconds and prints all eight results and request traces. Assertions capture the current faulty behavior, so it completes successfully on the affected revision.

Eight-case command reproduction
import assert from 'node:assert/strict';
import {mkdtemp,readFile,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join,resolve} from 'node:path';
import {pathToFileURL} from 'node:url';
import {createRequire} from 'node:module';

// Actual Commander command, HTTP validation, orchestration, polling and artifact
// writers. Only HTTP and the tunnel transport are fixtures; timers are real.
const root=resolve(process.argv[2]??'work/testsprite-cli');
const require=createRequire(join(root,'package.json'));
const {Command}=require('commander');
const {createTestCommand}=await import(pathToFileURL(join(root,'dist/commands/test.js')));
const temp=await mkdtemp(join(tmpdir(),'testsprite-mixed-verdict-'));
const cid='11111111-2222-3333-4444-555555555555';
const stamp='2026-10-17T00:00:00Z';
const json=body=>new Response(JSON.stringify(body),{status:200,headers:{'content-type':'application/json'}});
const scenarios=[
 {name:'single-backend-passed-control',ids:['be'],verdict:'passed',expectedExit:0},
 {name:'single-backend-failed-control',ids:['be'],verdict:'failed',expectedExit:1},
 {name:'mixed-backend-passed',ids:['fe','be'],verdict:'passed',expectedExit:7},
 {name:'mixed-backend-failed',ids:['fe','be'],verdict:'failed',expectedExit:7},
 {name:'mixed-longer-budget-passed',ids:['fe','be'],verdict:'passed',timeout:3,expectedExit:7},
 {name:'mixed-reversed-passed',ids:['be','fe'],verdict:'passed',expectedExit:7},
 {name:'two-frontend-control',ids:['fe','fe2'],verdict:'passed',expectedExit:0},
 {name:'mixed-terminal-backend-control',ids:['fe','be'],verdict:'passed',terminalBackend:true,expectedExit:0},
];
const results=[];
try{
 for(const s of scenarios){
  const trace=[],stdout=[],stderr=[],transport=[];
  const summaryFile=join(temp,s.name+'.json'),reportFile=join(temp,s.name+'.xml');
  const deps={
   env:{TESTSPRITE_API_KEY:'sk-user-offline-fixture',TESTSPRITE_API_URL:'https://mock.example.com'},
   credentialsPath:join(temp,'no-credentials'),
   stdout:x=>stdout.push(x),stderr:x=>stderr.push(x),
   createTunnelClient:()=>({start:async()=>{transport.push('start');},stop:async()=>{transport.push('stop');}}),
   fetchImpl:async(input,init={})=>{
    const u=new URL(typeof input==='string'?input:input.url??input.href);assert.equal(u.hostname,'mock.example.com');
    const path=u.pathname.replace('/api/cli/v1',''),method=init.method??'GET';
    trace.push({method,path,...(init.body?{body:JSON.parse(String(init.body))}:{})});
    if(method==='GET'&&/^\/tests\/(fe|fe2|be)$/.test(path)){
     const id=path.split('/')[2];return json({id,name:id,type:id==='be'?'backend':'frontend',projectId:id==='be'?'remote_project':'local_project',status:'ready',createdFrom:'cli',createdAt:stamp,updatedAt:stamp});
    }
    if(path==='/projects/local_project/env')return json({environments:[{name:'default',url:'http://127.0.0.1:5173',isDefault:true,isTemporary:false}]});
    if(path==='/projects/remote_project/env')return json({environments:[{name:'default',url:'https://example.com',isDefault:true,isTemporary:false}]});
    if(path==='/tunnel'&&method==='POST')return json({clientId:cid,secret:'fixture-secret',controlUrl:'ws://tunnel.example.com/ws',tunnelAddr:'tunnel.example.com:7400',expiresAt:'2099-01-01T00:00:00Z'});
    if(path==='/tunnel/'+cid&&method==='DELETE')return new Response(null,{status:204});
    const trigger=/^\/tests\/(fe|fe2|be)\/runs$/.exec(path);
    if(trigger&&method==='POST')return json({runId:'run_'+trigger[1],status:'queued',enqueuedAt:stamp,codeVersion:'v1',targetUrl:trigger[1]==='be'?'https://example.com':'http://127.0.0.1:5173'});
    if(path==='/tests/be/result')return json({testId:'be',type:'backend',status:s.verdict,startedAt:'2026-10-17T00:00:01Z',finishedAt:'2026-10-17T00:00:02Z',runIdIfAvailable:'run_be',snapshotId:'snap_fixture',codeVersion:'v1',targetUrl:null,failedStepIndex:s.verdict==='failed'?0:null,failureKind:s.verdict==='failed'?'assertion':null,videoUrl:null,summary:s.verdict});
    const poll=/^\/runs\/run_(fe|fe2|be)$/.exec(path);
    if(poll&&method==='GET'){
     const id=poll[1],pending=id==='be'&&!s.terminalBackend;
     return json({runId:'run_'+id,testId:id,projectId:id==='be'?'remote_project':'local_project',userId:'fixture_user',status:pending?'running':(id==='be'?s.verdict:'passed'),source:'cli',createdAt:stamp,startedAt:'2026-10-17T00:00:01Z',finishedAt:pending?null:'2026-10-17T00:00:02Z',codeVersion:'v1',targetUrl:id==='be'?null:'http://127.0.0.1:5173',createdFrom:'cli',failedStepIndex:null,failureKind:null,error:null,videoUrl:null,stepSummary:{total:1,completed:pending?0:1,passedCount:pending?0:1,failedCount:0},retryAfterSeconds:1});
    }
    throw Error('Unexpected fixture route '+method+' '+path);
   },
  };
  const args=['--output','json','test','run',...s.ids,'--wait','--timeout',String(s.timeout??1),'--skip-preflight','--summary-file',summaryFile];
  if(s.ids.length>1)args.push('--report','junit','--report-file',reportFile,'--report-suite-name','mixed-fixture');
  let error,exitCode=0;
  const started=performance.now();
  try{await new Command('testsprite').option('--output <mode>').addCommand(createTestCommand(deps)).parseAsync(args,{from:'user'});}
  catch(e){error={name:e.name,code:e.code,message:e.message,exitCode:e.exitCode};exitCode=e.exitCode??1;}
  assert.equal(exitCode,s.expectedExit,s.name+': '+JSON.stringify(error));
  const payload=JSON.parse(stdout.at(-1));
  const summary=JSON.parse(await readFile(summaryFile,'utf8'));
  const xml=s.ids.length>1?await readFile(reportFile,'utf8'):null;
  const backendReads=trace.filter(x=>x.path==='/tests/be/result').length;
  if(s.ids.length===1){assert.equal(payload.status,s.verdict);assert.equal(backendReads,1);}
  else if(s.expectedExit===7){
   assert.equal(backendReads,0);assert.equal(payload.results.find(x=>x.testId==='be').status,'timeout');
   assert.equal(payload.results.find(x=>x.testId==='fe').status,'passed');
   assert.equal(summary.timedOut,1);assert.equal(summary.passed,1);
   assert.ok(xml.includes('type="timeout"'));
   const beTrigger=trace.find(x=>x.method==='POST'&&x.path==='/tests/be/runs');
   assert.ok(beTrigger);assert.equal(beTrigger.body.tunnelClientId,undefined);
  }else assert.equal(summary.passed,2);
  results.push({name:s.name,args,exitCode,error,elapsedMs:performance.now()-started,backendResultReads:backendReads,payload,summary,xml,trace,transport,stderr});
 }
 console.log(JSON.stringify({node:process.version,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. You're right that the local-batch poller (L11983-L11992) doesn't use the backend-result fallback other poll sites use. But that fallback only matters if a backend test's run record never reaches a terminal status, as on the legacy platform. On the current backend those records are finalized server-side, so a completed backend test in a mixed batch should report its verdict, not a timeout; your offline repro uses the older response model.

    If you still see a timeout on v0.15.0 against the live service, please share the run ids. This stays open for now; otherwise we'll likely close it as not planned.

  2. added
    questionFurther information is requested
    and removed
    needs-triageNew issue/PR awaiting first triage
    on Oct 20, 2026
  3. zeshi-du commented on Nov 9, 2026

    @zeshi-du
    Contributor

    Closing as not planned: as explained above, the backend finalizes backend run records server-side, so this fallback path isn't hit in practice (see #522).

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 submissionsquestionFurther information is requested

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions