Skip to content

Commit 3fd703f

Browse files
Awad-deContributorcursoragent
authored
fix(poll): emit partial run to stdout on TimeoutError in run --wait and test wait (#153)
Apply the fix in src/commands/ (the compiled CLI path). When the overall --timeout polling deadline is exceeded, emit {runId, status:"running"} to stdout before exit 7 so JSON agents can chain into test wait. Co-authored-by: Contributor <contributor@testsprite.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e1d00f1 commit 3fd703f

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

src/commands/test.run.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,6 +1935,74 @@ describe('runTestRun --wait: Fix 3 — RequestTimeoutError writes partial JSON t
19351935
});
19361936
});
19371937

1938+
// ---------------------------------------------------------------------------
1939+
// TimeoutError on --wait: partial stdout + exit 7
1940+
// ---------------------------------------------------------------------------
1941+
1942+
describe('runTestRun --wait: TimeoutError writes partial JSON to stdout', () => {
1943+
it('exit 7 AND stdout contains {runId, status:"running"} when --timeout polling deadline is exceeded', async () => {
1944+
const { credentialsPath } = makeCreds();
1945+
let dateCallCount = 0;
1946+
let fetchCallCount = 0;
1947+
const base = Date.now();
1948+
const realDateNow = Date.now;
1949+
Date.now = () => (++dateCallCount > 6 ? base + 2000 : base);
1950+
1951+
try {
1952+
const fetchImpl: typeof globalThis.fetch = async () => {
1953+
++fetchCallCount;
1954+
if (fetchCallCount === 1) {
1955+
return new Response(JSON.stringify(TRIGGER_RESP), {
1956+
status: 200,
1957+
headers: { 'content-type': 'application/json' },
1958+
});
1959+
}
1960+
const runningRun: RunResponse = { ...makePassedRun(), status: 'running' };
1961+
return new Response(JSON.stringify(runningRun), {
1962+
status: 200,
1963+
headers: { 'content-type': 'application/json' },
1964+
});
1965+
};
1966+
1967+
const stdoutLines: string[] = [];
1968+
const stderrLines: string[] = [];
1969+
1970+
await expect(
1971+
runTestRun(
1972+
{
1973+
profile: 'default',
1974+
output: 'json',
1975+
debug: false,
1976+
verbose: false,
1977+
dryRun: false,
1978+
testId: 'test_xyz',
1979+
wait: true,
1980+
timeoutSeconds: 1,
1981+
},
1982+
{
1983+
credentialsPath,
1984+
fetchImpl: fetchImpl as unknown as FetchImpl,
1985+
stdout: line => stdoutLines.push(line),
1986+
stderr: line => stderrLines.push(line),
1987+
sleep: instantSleep,
1988+
},
1989+
),
1990+
).rejects.toMatchObject({ exitCode: 7 });
1991+
1992+
const stdoutJson = JSON.parse(stdoutLines.join('\n')) as {
1993+
runId: string;
1994+
status: string;
1995+
targetUrl: string;
1996+
};
1997+
expect(stdoutJson.runId).toBe(TRIGGER_RESP.runId);
1998+
expect(stdoutJson.status).toBe('running');
1999+
expect(stdoutJson.targetUrl).toBe(TRIGGER_RESP.targetUrl);
2000+
} finally {
2001+
Date.now = realDateNow;
2002+
}
2003+
});
2004+
});
2005+
19382006
// ---------------------------------------------------------------------------
19392007
// Fix 5 — B2(c): --timeout hint fires on default, not on explicit timeout
19402008
// ---------------------------------------------------------------------------

src/commands/test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4861,6 +4861,26 @@ export async function runTestRun(
48614861
} catch (err) {
48624862
if (err instanceof TimeoutError) {
48634863
ticker.finalize(`Run ${triggerResponse.runId} — timed out after ${opts.timeoutSeconds}s`);
4864+
// Mirror the RequestTimeoutError path: emit a partial run to stdout so
4865+
// JSON consumers and AI agents can grab the runId and chain into
4866+
// `testsprite test wait <runId>` without parsing the stderr error envelope.
4867+
const timeoutPartial = {
4868+
runId: triggerResponse.runId,
4869+
status: 'running' as const,
4870+
enqueuedAt: triggerResponse.enqueuedAt,
4871+
codeVersion: triggerResponse.codeVersion,
4872+
targetUrl: triggerResponse.targetUrl || null,
4873+
};
4874+
printRunOrChain(out, timeoutPartial, opts.createContext, data => {
4875+
const p = data as typeof timeoutPartial;
4876+
const lines = [
4877+
`runId ${p.runId}`,
4878+
`status ${p.status} (timed out after ${opts.timeoutSeconds}s)`,
4879+
];
4880+
if (p.targetUrl) lines.push(`targetUrl ${p.targetUrl}`);
4881+
lines.push(`hint Re-attach with: testsprite test wait ${p.runId}`);
4882+
return lines.join('\n');
4883+
});
48644884
throw ApiError.fromEnvelope({
48654885
error: {
48664886
code: 'UNSUPPORTED', // exit 7 per errors.md
@@ -5021,6 +5041,18 @@ export async function runTestWait(
50215041
} catch (err) {
50225042
if (err instanceof TimeoutError) {
50235043
ticker.finalize(`Run ${opts.runId} — timed out after ${opts.timeoutSeconds}s`);
5044+
// Mirror the RequestTimeoutError path: emit a partial run to stdout so
5045+
// JSON consumers and AI agents can grab the runId and chain into
5046+
// `testsprite test wait <runId>` without parsing the stderr error envelope.
5047+
const timeoutPartial = { runId: opts.runId, status: 'running' as const };
5048+
out.print(timeoutPartial, data => {
5049+
const p = data as typeof timeoutPartial;
5050+
return [
5051+
`runId ${p.runId}`,
5052+
`status ${p.status} (timed out after ${opts.timeoutSeconds}s)`,
5053+
`hint Re-attach with: testsprite test wait ${p.runId}`,
5054+
].join('\n');
5055+
});
50245056
throw ApiError.fromEnvelope({
50255057
error: {
50265058
code: 'UNSUPPORTED', // exit 7 per errors.md

src/commands/test.wait.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,69 @@ describe('runTestWait: Fix 3 — RequestTimeoutError writes partial JSON to stdo
10211021
});
10221022
});
10231023

1024+
// ---------------------------------------------------------------------------
1025+
// TimeoutError on test wait: partial stdout + exit 7
1026+
// ---------------------------------------------------------------------------
1027+
1028+
describe('runTestWait: TimeoutError writes partial JSON to stdout', () => {
1029+
let logSpy: ReturnType<typeof vi.spyOn>;
1030+
let errorSpy: ReturnType<typeof vi.spyOn>;
1031+
1032+
beforeEach(() => {
1033+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1034+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1035+
});
1036+
1037+
afterEach(() => {
1038+
logSpy.mockRestore();
1039+
errorSpy.mockRestore();
1040+
});
1041+
1042+
it('exit 7 AND stdout contains {runId, status:"running"} when --timeout polling deadline is exceeded', async () => {
1043+
const { credentialsPath } = makeCreds();
1044+
const fetchImpl = makeFetch(() => ({ body: makeRun('running') }));
1045+
1046+
let callCount = 0;
1047+
const base = Date.now();
1048+
const realDateNow = Date.now;
1049+
Date.now = () => (callCount++ > 4 ? base + 2000 : base);
1050+
1051+
try {
1052+
const stdoutLines: string[] = [];
1053+
const stderrLines: string[] = [];
1054+
1055+
await expect(
1056+
runTestWait(
1057+
{
1058+
profile: 'default',
1059+
output: 'json',
1060+
debug: false,
1061+
dryRun: false,
1062+
runId: 'run_abc',
1063+
timeoutSeconds: 1,
1064+
},
1065+
{
1066+
credentialsPath,
1067+
fetchImpl,
1068+
stdout: line => stdoutLines.push(line),
1069+
stderr: line => stderrLines.push(line),
1070+
sleep: instantSleep,
1071+
},
1072+
),
1073+
).rejects.toMatchObject({ exitCode: 7 });
1074+
1075+
const stdoutJson = JSON.parse(stdoutLines.join('\n')) as {
1076+
runId: string;
1077+
status: string;
1078+
};
1079+
expect(stdoutJson.runId).toBe('run_abc');
1080+
expect(stdoutJson.status).toBe('running');
1081+
} finally {
1082+
Date.now = realDateNow;
1083+
}
1084+
});
1085+
});
1086+
10241087
// ---------------------------------------------------------------------------
10251088
// FIX 4 — D5-UX: text mode shows the `error` string for failed/blocked runs
10261089
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)