Skip to content

Commit 91a4641

Browse files
committed
feat(test): make "test wait" variadic — attach to several runs in one invocation
1 parent b9e9601 commit 91a4641

3 files changed

Lines changed: 341 additions & 8 deletions

File tree

src/commands/test.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
runPlanPut,
3737
runResult,
3838
runSteps,
39+
runTestWaitMany,
3940
runUpdate,
4041
} from './test.js';
4142

@@ -3042,6 +3043,124 @@ describe('runLint', () => {
30423043
});
30433044
});
30443045

3046+
describe('runTestWaitMany', () => {
3047+
const terminalRun = (runId: string, status: string) => ({
3048+
runId,
3049+
testId: `test_of_${runId}`,
3050+
projectId: 'project_alice',
3051+
userId: 'u1',
3052+
status,
3053+
source: 'cli',
3054+
createdAt: '2026-06-01T10:00:00.000Z',
3055+
startedAt: '2026-06-01T10:00:01.000Z',
3056+
finishedAt: '2026-06-01T10:00:30.000Z',
3057+
codeVersion: 'v1',
3058+
targetUrl: 'https://example.com',
3059+
createdFrom: null,
3060+
failedStepIndex: null,
3061+
failureKind: null,
3062+
error: null,
3063+
videoUrl: null,
3064+
stepSummary: { total: 1, completed: 1, passedCount: 1, failedCount: 0 },
3065+
});
3066+
3067+
it('polls every run, keeps input order, and exits 1 when one finished non-passed', async () => {
3068+
const { credentialsPath } = makeCreds();
3069+
const fetchImpl = makeFetch(url => ({
3070+
body: url.includes('run_bad')
3071+
? terminalRun('run_bad', 'failed')
3072+
: terminalRun('run_ok', 'passed'),
3073+
}));
3074+
const out: string[] = [];
3075+
const rejection = await runTestWaitMany(
3076+
{
3077+
profile: 'default',
3078+
output: 'json',
3079+
debug: false,
3080+
runIds: ['run_ok', 'run_bad'],
3081+
timeoutSeconds: 30,
3082+
maxConcurrency: 2,
3083+
},
3084+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3085+
).catch((error: unknown) => error);
3086+
expect(rejection).toMatchObject({ exitCode: 1 });
3087+
const payload = JSON.parse(out.join('')) as {
3088+
results: Array<{ runId: string; status: string }>;
3089+
summary: { passed: number; failed: number };
3090+
};
3091+
expect(payload.results.map(row => row.runId)).toEqual(['run_ok', 'run_bad']);
3092+
expect(payload.summary).toMatchObject({ passed: 1, failed: 1 });
3093+
});
3094+
3095+
it('a member whose poll errors is captured as error:<CODE> and the others survive (exit 7)', async () => {
3096+
const { credentialsPath } = makeCreds();
3097+
const fetchImpl = makeFetch(url => {
3098+
if (url.includes('run_gone')) {
3099+
return {
3100+
status: 404,
3101+
body: {
3102+
error: {
3103+
code: 'NOT_FOUND',
3104+
message: 'no such run',
3105+
nextAction: 'check the id',
3106+
requestId: 'req_x',
3107+
details: {},
3108+
},
3109+
},
3110+
};
3111+
}
3112+
return { body: terminalRun('run_ok', 'passed') };
3113+
});
3114+
const out: string[] = [];
3115+
const rejection = await runTestWaitMany(
3116+
{
3117+
profile: 'default',
3118+
output: 'json',
3119+
debug: false,
3120+
runIds: ['run_ok', 'run_gone'],
3121+
timeoutSeconds: 30,
3122+
maxConcurrency: 2,
3123+
},
3124+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3125+
).catch((error: unknown) => error);
3126+
expect(rejection).toMatchObject({ exitCode: 7 });
3127+
const payload = JSON.parse(out.join('')) as {
3128+
results: Array<{ runId: string; status: string }>;
3129+
};
3130+
// The failing member did not abort the pool: the passed verdict survived.
3131+
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
3132+
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
3133+
});
3134+
3135+
it('an auth error escalates the exit code to 3', async () => {
3136+
const { credentialsPath } = makeCreds();
3137+
const fetchImpl = makeFetch(() => ({
3138+
status: 401,
3139+
body: {
3140+
error: {
3141+
code: 'AUTH_INVALID',
3142+
message: 'bad key',
3143+
nextAction: 'run setup',
3144+
requestId: 'req_y',
3145+
details: {},
3146+
},
3147+
},
3148+
}));
3149+
const rejection = await runTestWaitMany(
3150+
{
3151+
profile: 'default',
3152+
output: 'json',
3153+
debug: false,
3154+
runIds: ['run_a', 'run_b'],
3155+
timeoutSeconds: 30,
3156+
maxConcurrency: 2,
3157+
},
3158+
{ credentialsPath, fetchImpl, stdout: () => undefined },
3159+
).catch((error: unknown) => error);
3160+
expect(rejection).toMatchObject({ exitCode: 3 });
3161+
});
3162+
});
3163+
30453164
describe('runResult', () => {
30463165
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
30473166
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 215 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5331,6 +5331,188 @@ export async function runTestRun(
53315331
return finalRun;
53325332
}
53335333

5334+
/** One row of the `test wait <run-id...>` multi-run payload. */
5335+
export interface CliMultiWaitResult {
5336+
runId: string;
5337+
/** Terminal run status, or 'timeout', or 'error:<CODE>' when the poll failed. */
5338+
status: string;
5339+
/** Test the run belongs to, when the poll observed it. */
5340+
testId?: string;
5341+
}
5342+
5343+
export interface RunTestWaitManyOptions extends CommonOptions {
5344+
runIds: string[];
5345+
timeoutSeconds: number;
5346+
maxConcurrency: number;
5347+
}
5348+
5349+
/**
5350+
* `test wait <run-id...>` with two or more ids: attach to N already-dispatched
5351+
* runs in ONE invocation. This closes the loop the CLI itself opens: every
5352+
* batch/closure timeout prints one `testsprite test wait <runId>` hint PER
5353+
* member, which previously meant N sequential blocking invocations. The runs
5354+
* are polled concurrently under a bounded pool with ONE shared deadline
5355+
* (`--timeout` bounds the whole invocation, not each member), each member's
5356+
* poll is total (a transient error on one run never discards the others), and
5357+
* the exit code is the worst status across members: auth errors escalate to
5358+
* exit 3, any timeout or poll error exits 7, any non-passed terminal exits 1.
5359+
* Distinct from a run journal (issue #80): no persistence, just N known ids.
5360+
*/
5361+
export async function runTestWaitMany(
5362+
opts: RunTestWaitManyOptions,
5363+
deps: TestDeps = {},
5364+
): Promise<{ results: CliMultiWaitResult[]; summary: Record<string, number> }> {
5365+
const out = makeOutput(opts.output, deps);
5366+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
5367+
5368+
if (opts.dryRun) {
5369+
emitDryRunBanner(stderrFn);
5370+
const results: CliMultiWaitResult[] = opts.runIds.map(runId => ({
5371+
runId,
5372+
status: 'passed',
5373+
}));
5374+
const payload = {
5375+
results,
5376+
summary: { total: results.length, passed: results.length, failed: 0, timedOut: 0, errors: 0 },
5377+
};
5378+
out.print(payload, () => results.map(r => `${r.runId} ${r.status}`).join('\n'));
5379+
return payload;
5380+
}
5381+
5382+
const client = makeClient(
5383+
{ ...opts, requestTimeoutMs: resolveWaitRequestTimeoutMs({ ...opts, wait: true }) },
5384+
deps,
5385+
);
5386+
const ticker = createTicker(stderrFn, opts.output === 'json' ? false : undefined);
5387+
5388+
// One shared deadline across every member (the whole point of the shared
5389+
// pool: `--timeout 600` means the invocation ends within ~600s, not
5390+
// 600s x ceil(N/concurrency)).
5391+
const deadlineMs = Date.now() + opts.timeoutSeconds * 1000;
5392+
5393+
type WaitOutcome =
5394+
| { kind: 'result'; run: RunResponse }
5395+
| { kind: 'timeout' }
5396+
| { kind: 'error'; code: string; exitCode: number };
5397+
5398+
const pollOne = async (runId: string): Promise<WaitOutcome> => {
5399+
const resolveAlternate = makeBackendWaitFallback({
5400+
client,
5401+
resolveTestId: run => run.testId,
5402+
resolveNotBefore: run => run.createdAt,
5403+
onResolved: () => undefined,
5404+
});
5405+
try {
5406+
const run = await pollRunUntilTerminal(client, runId, {
5407+
timeoutSeconds: Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000)),
5408+
sleep: deps.sleep,
5409+
onTransition: opts.verbose ? (msg: string) => stderrFn(`[verbose] ${msg}`) : undefined,
5410+
onTick: (run, elapsedMs) => {
5411+
const elapsed = Math.round(elapsedMs / 1000);
5412+
ticker.update(`Run ${run.runId}${run.status} (elapsed=${elapsed}s)`);
5413+
},
5414+
resolveAlternate,
5415+
});
5416+
return { kind: 'result', run };
5417+
} catch (err) {
5418+
if (err instanceof TimeoutError) return { kind: 'timeout' };
5419+
if (err instanceof RequestTimeoutError) throw err;
5420+
if (err instanceof ApiError) return { kind: 'error', code: err.code, exitCode: err.exitCode };
5421+
return { kind: 'error', code: 'TRANSPORT', exitCode: 10 };
5422+
}
5423+
};
5424+
5425+
const outcomes = new Map<string, WaitOutcome>();
5426+
let inFlight = 0;
5427+
let nextIdx = 0;
5428+
try {
5429+
await new Promise<void>((resolve, reject) => {
5430+
const startNext = (): void => {
5431+
while (inFlight < opts.maxConcurrency && nextIdx < opts.runIds.length) {
5432+
const runId = opts.runIds[nextIdx++]!;
5433+
inFlight++;
5434+
pollOne(runId)
5435+
.then(outcome => {
5436+
outcomes.set(runId, outcome);
5437+
inFlight--;
5438+
startNext();
5439+
if (inFlight === 0 && nextIdx >= opts.runIds.length) resolve();
5440+
})
5441+
// pollOne is total except for RequestTimeoutError (handled below).
5442+
.catch(reject);
5443+
}
5444+
};
5445+
startNext();
5446+
if (opts.runIds.length === 0) resolve();
5447+
});
5448+
} catch (fanOutErr) {
5449+
if (fanOutErr instanceof RequestTimeoutError) {
5450+
// Same contract as the batch pollers: leave stdout parseable before
5451+
// exiting 7 — every dispatched id is re-attachable in one command.
5452+
ticker.finalize('Multi-run wait — request timed out');
5453+
const partial = {
5454+
results: opts.runIds.map(runId => ({ runId, status: 'running' })),
5455+
summary: { total: opts.runIds.length },
5456+
};
5457+
out.print(partial, () => partial.results.map(r => `${r.runId} running`).join('\n'));
5458+
stderrFn(`Re-attach with: testsprite test wait ${opts.runIds.join(' ')}`);
5459+
}
5460+
throw fanOutErr;
5461+
}
5462+
ticker.finalize();
5463+
5464+
const results: CliMultiWaitResult[] = opts.runIds.map(runId => {
5465+
const outcome = outcomes.get(runId);
5466+
if (outcome === undefined || outcome.kind === 'timeout') return { runId, status: 'timeout' };
5467+
if (outcome.kind === 'error') return { runId, status: `error:${outcome.code}` };
5468+
return { runId, status: outcome.run.status, testId: outcome.run.testId };
5469+
});
5470+
const passed = results.filter(r => r.status === 'passed').length;
5471+
const timedOut = results.filter(r => r.status === 'timeout').length;
5472+
const errors = results.filter(r => r.status.startsWith('error:')).length;
5473+
const failed = results.length - passed - timedOut - errors;
5474+
const payload = {
5475+
results,
5476+
summary: { total: results.length, passed, failed, timedOut, errors },
5477+
};
5478+
out.print(payload, () =>
5479+
[
5480+
...results.map(r => `${r.runId} ${r.status}`),
5481+
'',
5482+
`${passed}/${results.length} passed, ${failed} failed/blocked, ${timedOut} timed out, ${errors} poll errors`,
5483+
].join('\n'),
5484+
);
5485+
5486+
const timedOutIds = results.filter(r => r.status === 'timeout').map(r => r.runId);
5487+
if (timedOutIds.length > 0) {
5488+
stderrFn(`Re-attach with: testsprite test wait ${timedOutIds.join(' ')}`);
5489+
}
5490+
5491+
// Worst-status exit: auth escalates (a rejected key fails every member the
5492+
// same way), then timeout/poll-error (7, resumable), then plain failure (1).
5493+
const authError = [...outcomes.values()].find(
5494+
o =>
5495+
o.kind === 'error' &&
5496+
(o.code === 'AUTH_REQUIRED' || o.code === 'AUTH_INVALID' || o.code === 'AUTH_FORBIDDEN'),
5497+
);
5498+
if (authError !== undefined && authError.kind === 'error') {
5499+
throw new CLIError(
5500+
`Multi-run wait: authentication failed (${authError.code})`,
5501+
authError.exitCode,
5502+
);
5503+
}
5504+
if (timedOut > 0 || errors > 0) {
5505+
throw new CLIError(
5506+
`Multi-run wait: ${timedOut} timed out, ${errors} poll error(s) out of ${results.length} runs`,
5507+
7,
5508+
);
5509+
}
5510+
if (failed > 0) {
5511+
throw new CLIError(`Multi-run wait: ${failed} run(s) finished non-passed`, 1);
5512+
}
5513+
return payload;
5514+
}
5515+
53345516
/**
53355517
* `test wait <run-id>` — M3.3 piece-3.
53365518
*
@@ -8140,26 +8322,52 @@ export function createTestCommand(deps: TestDeps = {}): Command {
81408322
});
81418323

81428324
test
8143-
.command('wait <run-id>')
8325+
.command('wait <run-id...>')
81448326
.description(
8145-
'Wait for a run to reach a terminal status.\n' +
8327+
'Wait for one or more runs to reach a terminal status.\n' +
8328+
'\nWith several run-ids the runs are polled concurrently under one shared\n' +
8329+
'--timeout and a {results, summary} envelope is printed (worst status wins\n' +
8330+
'the exit code), so every re-attach hint the CLI prints can be pasted as\n' +
8331+
'ONE command.\n' +
81468332
'\nExit codes:\n' +
81478333
' 0 passed\n' +
81488334
' 1 failed / blocked / cancelled\n' +
81498335
' 3 auth error\n' +
81508336
' 4 run not found\n' +
8151-
' 7 timeout — resume with: testsprite test wait <run-id>\n' +
8337+
' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
81528338
' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
81538339
'\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>',
81548340
)
81558341
.option('--timeout <s>', `max seconds to wait (1–3600, default ${DEFAULT_RUN_TIMEOUT_SECONDS})`)
8342+
.option(
8343+
'--max-concurrency <n>',
8344+
'with several run-ids, max concurrent polls (1-100, default: 10)',
8345+
)
81568346
.addHelpText('after', GLOBAL_OPTS_HINT)
8157-
.action(async (runId: string, cmdOpts: WaitFlagOpts, command: Command) => {
8158-
await runTestWait(
8347+
.action(async (runIds: string[], cmdOpts: WaitFlagOpts, command: Command) => {
8348+
// One id keeps the historical single-run path byte-identical (same
8349+
// output shape, same exit codes); two or more fan out.
8350+
if (runIds.length === 1) {
8351+
await runTestWait(
8352+
{
8353+
...resolveCommonOptions(command),
8354+
runId: runIds[0]!,
8355+
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
8356+
},
8357+
deps,
8358+
);
8359+
return;
8360+
}
8361+
const maxConcurrency = parseNumericFlag(cmdOpts.maxConcurrency, 'max-concurrency') ?? 10;
8362+
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 100) {
8363+
throw localValidationError('max-concurrency', 'must be an integer between 1 and 100');
8364+
}
8365+
await runTestWaitMany(
81598366
{
81608367
...resolveCommonOptions(command),
8161-
runId,
8368+
runIds,
81628369
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
8370+
maxConcurrency,
81638371
},
81648372
deps,
81658373
);
@@ -8545,6 +8753,7 @@ interface RunFlagOpts {
85458753

85468754
interface WaitFlagOpts {
85478755
timeout?: string;
8756+
maxConcurrency?: string;
85488757
}
85498758

85508759
interface RerunFlagOpts {

0 commit comments

Comments
 (0)