Repository navigation
Description
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
feis a frontend test inlocal_project; that project's default environment URL ishttp://127.0.0.1:5173.beis a backend test inremote_project; that project's default environment URL ishttps://example.com.- 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-fixtureThe 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.
- 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_bethroughrunIdIfAvailable.
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
- Multi-ID command routing enters
runTestRunLocalBatch. - Per-test target resolution includes backend tests when local/target overrides are absent, skips them only when they themselves resolve to a tunnel target, and retains the remote backend in
testswhen another test supplies the tunnel. - Local-batch polling passes
resolveAlternateonly for borrowed-tunnel liveness, never for a remote backend result. - The existing backend fallback contract and implementation already cover non-terminal run cards, exact run-ID correlation and the test-scoped terminal result. Existing single-run backend fallback tests use this response model.
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.
- [Hackathon] fix(test):
test diffcompares runs that have no verdict yet — in-flight runs diff as "unchanged" (exit 0), and backend runs can never fail the gate #378 is the read-onlytest diffcomparison path. This report is the distinct multi-ID automatic-tunnel execution/poll path, which dispatches runs and waits but omits a fallback already used by ordinarytest run. - [Hackathon] fix(test): batch --wait poll drops failure diagnostics from accepted[] rows #448 drops diagnostics after a terminal run was returned. Here the poller never obtains the terminal verdict at all and misclassifies success/failure as timeout.
- [Hackathon] bug: backend failures recommend the wrong artifact command in JSON waits and closure reruns #432 / [Hackathon] bug: backend failures recommend the wrong artifact command in JSON waits and closure reruns #423 concern the recovery-command hint after an already-detected backend failure, not verdict detection.
- [Hackathon] fix(poll): a 7-second network blip ends any --wait with exit 10, and a silently dropped connection stalls it for 300s #437 concerns transport failures. Every request in this reproduction receives a successful, schema-valid response.
- [Hackathon] fix(test run): preserve skipped tests returned by deferred retries #477 concerns merging skipped records from fresh-batch deferred retries; this reproduction has no deferred retry or skipped response.
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
zeshi-du commentedon Oct 20, 2026
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.
zeshi-du commentedon Nov 9, 2026
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).