Skip to content

Commit 94a1d42

Browse files
Contributorcursoragent
andcommitted
fix(batch): classify RequestTimeoutError as timeout in --all --wait fan-out
When test run --all --wait or test rerun --all --wait hit a per-request timeout during batch fan-out polling, RequestTimeoutError rejected the fan-out before out.print(). Classify it as status:'timeout' in pollFreshAccepted and pollAccepted so stdout always lists every dispatched runId. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3ab8136 commit 94a1d42

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

src/commands/test.rerun.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4630,3 +4630,66 @@ describe('rerun --wait — dashboardUrl on terminal output', () => {
46304630
);
46314631
});
46324632
});
4633+
4634+
// ---------------------------------------------------------------------------
4635+
// Batch --all --wait fan-out: RequestTimeoutError must not leave stdout empty
4636+
// ---------------------------------------------------------------------------
4637+
4638+
describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out poll writes JSON stdout + exit 7', () => {
4639+
it('stdout contains accepted[] with runIds when member polls throw RequestTimeoutError', async () => {
4640+
const creds = makeCreds();
4641+
const batchResp: BatchRerunResponse = {
4642+
accepted: [
4643+
{ testId: 'test_1', runId: 'run_b1', enqueuedAt: '2026-06-03T10:00:00.000Z' },
4644+
{ testId: 'test_2', runId: 'run_b2', enqueuedAt: '2026-06-03T10:00:00.000Z' },
4645+
],
4646+
deferred: [],
4647+
conflicts: [],
4648+
closure: { byProject: [] },
4649+
};
4650+
const fetchImpl = makeFetch(url => {
4651+
if (url.includes('/tests/batch/rerun')) {
4652+
return { status: 202, body: batchResp };
4653+
}
4654+
if (url.includes('/runs/')) {
4655+
throw new RequestTimeoutError(120000, 'req_timeout_batch_rerun');
4656+
}
4657+
return errorBody('NOT_FOUND');
4658+
});
4659+
const stdoutLines: string[] = [];
4660+
4661+
const err = await runTestRerun(
4662+
{
4663+
testIds: ['test_1', 'test_2'],
4664+
all: false,
4665+
wait: true,
4666+
timeoutSeconds: 60,
4667+
autoHeal: false,
4668+
autoHealExplicit: false,
4669+
skipDependencies: false,
4670+
maxConcurrency: 10,
4671+
output: 'json',
4672+
profile: 'default',
4673+
dryRun: false,
4674+
debug: false,
4675+
verbose: false,
4676+
},
4677+
{
4678+
...creds,
4679+
sleep: instantSleep,
4680+
fetchImpl: fetchImpl as unknown as FetchImpl,
4681+
stdout: line => stdoutLines.push(line),
4682+
stderr: () => undefined,
4683+
},
4684+
).catch(e => e);
4685+
4686+
expect(err).toMatchObject({ exitCode: 7 });
4687+
expect(stdoutLines.length).toBeGreaterThan(0);
4688+
const parsed = JSON.parse(stdoutLines.join('\n')) as {
4689+
accepted: Array<{ testId: string; runId: string; status: string }>;
4690+
};
4691+
expect(parsed.accepted).toHaveLength(2);
4692+
expect(parsed.accepted.map(r => r.runId).sort()).toEqual(['run_b1', 'run_b2']);
4693+
expect(parsed.accepted.every(r => r.status === 'timeout')).toBe(true);
4694+
});
4695+
});

src/commands/test.run.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3536,3 +3536,59 @@ describe('dashboardUrl on run completion', () => {
35363536
).toBe(true);
35373537
});
35383538
});
3539+
3540+
// ---------------------------------------------------------------------------
3541+
// Batch --all --wait fan-out: RequestTimeoutError must not leave stdout empty
3542+
// ---------------------------------------------------------------------------
3543+
3544+
describe('[finding-5] runTestRunAll --wait: RequestTimeoutError during fan-out poll writes JSON stdout + exit 7', () => {
3545+
it('stdout contains accepted[] with runIds when member polls throw RequestTimeoutError', async () => {
3546+
const { credentialsPath } = makeCreds();
3547+
const batchResp: BatchRunFreshResponse = {
3548+
accepted: [
3549+
{ testId: 'test_be_01', runId: 'run_fresh_01', enqueuedAt: '2026-06-09T10:00:00.000Z' },
3550+
{ testId: 'test_be_02', runId: 'run_fresh_02', enqueuedAt: '2026-06-09T10:00:01.000Z' },
3551+
],
3552+
conflicts: [],
3553+
deferred: [],
3554+
skippedFrontend: [],
3555+
skippedIntegration: [],
3556+
};
3557+
const fetchImpl = makeFetch((url, init) => {
3558+
if ((init.method ?? 'GET') === 'POST') return { body: batchResp };
3559+
if (url.includes('/runs/')) {
3560+
throw new RequestTimeoutError(120000, 'req_timeout_batch_all');
3561+
}
3562+
return errorBody('NOT_FOUND');
3563+
});
3564+
const stdoutLines: string[] = [];
3565+
3566+
const err = await runTestRunAll(
3567+
{
3568+
profile: 'default',
3569+
output: 'json',
3570+
debug: false,
3571+
projectId: 'project_be',
3572+
wait: true,
3573+
timeoutSeconds: 60,
3574+
maxConcurrency: 5,
3575+
},
3576+
{
3577+
credentialsPath,
3578+
fetchImpl,
3579+
stdout: line => stdoutLines.push(line),
3580+
stderr: () => undefined,
3581+
sleep: instantSleep,
3582+
},
3583+
).catch(e => e);
3584+
3585+
expect(err).toMatchObject({ exitCode: 7 });
3586+
expect(stdoutLines.length).toBeGreaterThan(0);
3587+
const parsed = JSON.parse(stdoutLines.join('\n')) as {
3588+
accepted: Array<{ testId: string; runId: string; status: string }>;
3589+
};
3590+
expect(parsed.accepted).toHaveLength(2);
3591+
expect(parsed.accepted.map(r => r.runId).sort()).toEqual(['run_fresh_01', 'run_fresh_02']);
3592+
expect(parsed.accepted.every(r => r.status === 'timeout')).toBe(true);
3593+
});
3594+
});

src/commands/test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5456,6 +5456,22 @@ export async function runTestRunAll(
54565456
},
54575457
};
54585458
}
5459+
if (err instanceof RequestTimeoutError) {
5460+
// Client-side per-request timeout during polling — classify as timeout
5461+
// (exit 7) so the fan-out completes and stdout carries every runId.
5462+
// Without this, RequestTimeoutError rejects the fan-out before out.print(),
5463+
// leaving JSON consumers with empty stdout (mirrors create-batch --run).
5464+
return {
5465+
testId: entry.testId,
5466+
runId,
5467+
status: 'timeout',
5468+
error: {
5469+
code: 'UNSUPPORTED',
5470+
message: err.message,
5471+
exitCode: err.exitCode,
5472+
},
5473+
};
5474+
}
54595475
if (err instanceof ApiError) {
54605476
// Preserve the real exit code + envelope (AUTH_INVALID=3, NOT_FOUND=4,
54615477
// RATE_LIMITED=11, …) instead of flattening every member failure to 1
@@ -6608,6 +6624,22 @@ export async function runTestRerun(
66086624
},
66096625
};
66106626
}
6627+
if (err instanceof RequestTimeoutError) {
6628+
// Client-side per-request timeout during polling — classify as timeout
6629+
// (exit 7) so the fan-out completes and stdout carries every runId.
6630+
// Without this, RequestTimeoutError rejects the fan-out before out.print(),
6631+
// leaving JSON consumers with empty stdout (mirrors create-batch --run).
6632+
return {
6633+
testId: entry.testId,
6634+
runId: entry.runId,
6635+
status: 'timeout',
6636+
error: {
6637+
code: 'UNSUPPORTED',
6638+
message: err.message,
6639+
exitCode: err.exitCode,
6640+
},
6641+
};
6642+
}
66116643
if (err instanceof ApiError) {
66126644
return {
66136645
testId: entry.testId,

0 commit comments

Comments
 (0)