Skip to content

Commit 5e05fd5

Browse files
committed
feat(test): make "test wait" variadic — attach to several runs in one invocation
1 parent 2ddb03d commit 5e05fd5

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
@@ -35,6 +35,7 @@ import {
3535
runPlanPut,
3636
runResult,
3737
runSteps,
38+
runTestWaitMany,
3839
runUpdate,
3940
} from './test.js';
4041

@@ -2960,6 +2961,124 @@ describe('runDiff', () => {
29602961
});
29612962
});
29622963

2964+
describe('runTestWaitMany', () => {
2965+
const terminalRun = (runId: string, status: string) => ({
2966+
runId,
2967+
testId: `test_of_${runId}`,
2968+
projectId: 'project_alice',
2969+
userId: 'u1',
2970+
status,
2971+
source: 'cli',
2972+
createdAt: '2026-06-01T10:00:00.000Z',
2973+
startedAt: '2026-06-01T10:00:01.000Z',
2974+
finishedAt: '2026-06-01T10:00:30.000Z',
2975+
codeVersion: 'v1',
2976+
targetUrl: 'https://example.com',
2977+
createdFrom: null,
2978+
failedStepIndex: null,
2979+
failureKind: null,
2980+
error: null,
2981+
videoUrl: null,
2982+
stepSummary: { total: 1, completed: 1, passedCount: 1, failedCount: 0 },
2983+
});
2984+
2985+
it('polls every run, keeps input order, and exits 1 when one finished non-passed', async () => {
2986+
const { credentialsPath } = makeCreds();
2987+
const fetchImpl = makeFetch(url => ({
2988+
body: url.includes('run_bad')
2989+
? terminalRun('run_bad', 'failed')
2990+
: terminalRun('run_ok', 'passed'),
2991+
}));
2992+
const out: string[] = [];
2993+
const rejection = await runTestWaitMany(
2994+
{
2995+
profile: 'default',
2996+
output: 'json',
2997+
debug: false,
2998+
runIds: ['run_ok', 'run_bad'],
2999+
timeoutSeconds: 30,
3000+
maxConcurrency: 2,
3001+
},
3002+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3003+
).catch((error: unknown) => error);
3004+
expect(rejection).toMatchObject({ exitCode: 1 });
3005+
const payload = JSON.parse(out.join('')) as {
3006+
results: Array<{ runId: string; status: string }>;
3007+
summary: { passed: number; failed: number };
3008+
};
3009+
expect(payload.results.map(row => row.runId)).toEqual(['run_ok', 'run_bad']);
3010+
expect(payload.summary).toMatchObject({ passed: 1, failed: 1 });
3011+
});
3012+
3013+
it('a member whose poll errors is captured as error:<CODE> and the others survive (exit 7)', async () => {
3014+
const { credentialsPath } = makeCreds();
3015+
const fetchImpl = makeFetch(url => {
3016+
if (url.includes('run_gone')) {
3017+
return {
3018+
status: 404,
3019+
body: {
3020+
error: {
3021+
code: 'NOT_FOUND',
3022+
message: 'no such run',
3023+
nextAction: 'check the id',
3024+
requestId: 'req_x',
3025+
details: {},
3026+
},
3027+
},
3028+
};
3029+
}
3030+
return { body: terminalRun('run_ok', 'passed') };
3031+
});
3032+
const out: string[] = [];
3033+
const rejection = await runTestWaitMany(
3034+
{
3035+
profile: 'default',
3036+
output: 'json',
3037+
debug: false,
3038+
runIds: ['run_ok', 'run_gone'],
3039+
timeoutSeconds: 30,
3040+
maxConcurrency: 2,
3041+
},
3042+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3043+
).catch((error: unknown) => error);
3044+
expect(rejection).toMatchObject({ exitCode: 7 });
3045+
const payload = JSON.parse(out.join('')) as {
3046+
results: Array<{ runId: string; status: string }>;
3047+
};
3048+
// The failing member did not abort the pool: the passed verdict survived.
3049+
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
3050+
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
3051+
});
3052+
3053+
it('an auth error escalates the exit code to 3', async () => {
3054+
const { credentialsPath } = makeCreds();
3055+
const fetchImpl = makeFetch(() => ({
3056+
status: 401,
3057+
body: {
3058+
error: {
3059+
code: 'AUTH_INVALID',
3060+
message: 'bad key',
3061+
nextAction: 'run setup',
3062+
requestId: 'req_y',
3063+
details: {},
3064+
},
3065+
},
3066+
}));
3067+
const rejection = await runTestWaitMany(
3068+
{
3069+
profile: 'default',
3070+
output: 'json',
3071+
debug: false,
3072+
runIds: ['run_a', 'run_b'],
3073+
timeoutSeconds: 30,
3074+
maxConcurrency: 2,
3075+
},
3076+
{ credentialsPath, fetchImpl, stdout: () => undefined },
3077+
).catch((error: unknown) => error);
3078+
expect(rejection).toMatchObject({ exitCode: 3 });
3079+
});
3080+
});
3081+
29633082
describe('runResult', () => {
29643083
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
29653084
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 215 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5185,6 +5185,188 @@ export async function runTestRun(
51855185
return finalRun;
51865186
}
51875187

5188+
/** One row of the `test wait <run-id...>` multi-run payload. */
5189+
export interface CliMultiWaitResult {
5190+
runId: string;
5191+
/** Terminal run status, or 'timeout', or 'error:<CODE>' when the poll failed. */
5192+
status: string;
5193+
/** Test the run belongs to, when the poll observed it. */
5194+
testId?: string;
5195+
}
5196+
5197+
export interface RunTestWaitManyOptions extends CommonOptions {
5198+
runIds: string[];
5199+
timeoutSeconds: number;
5200+
maxConcurrency: number;
5201+
}
5202+
5203+
/**
5204+
* `test wait <run-id...>` with two or more ids: attach to N already-dispatched
5205+
* runs in ONE invocation. This closes the loop the CLI itself opens: every
5206+
* batch/closure timeout prints one `testsprite test wait <runId>` hint PER
5207+
* member, which previously meant N sequential blocking invocations. The runs
5208+
* are polled concurrently under a bounded pool with ONE shared deadline
5209+
* (`--timeout` bounds the whole invocation, not each member), each member's
5210+
* poll is total (a transient error on one run never discards the others), and
5211+
* the exit code is the worst status across members: auth errors escalate to
5212+
* exit 3, any timeout or poll error exits 7, any non-passed terminal exits 1.
5213+
* Distinct from a run journal (issue #80): no persistence, just N known ids.
5214+
*/
5215+
export async function runTestWaitMany(
5216+
opts: RunTestWaitManyOptions,
5217+
deps: TestDeps = {},
5218+
): Promise<{ results: CliMultiWaitResult[]; summary: Record<string, number> }> {
5219+
const out = makeOutput(opts.output, deps);
5220+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
5221+
5222+
if (opts.dryRun) {
5223+
emitDryRunBanner(stderrFn);
5224+
const results: CliMultiWaitResult[] = opts.runIds.map(runId => ({
5225+
runId,
5226+
status: 'passed',
5227+
}));
5228+
const payload = {
5229+
results,
5230+
summary: { total: results.length, passed: results.length, failed: 0, timedOut: 0, errors: 0 },
5231+
};
5232+
out.print(payload, () => results.map(r => `${r.runId} ${r.status}`).join('\n'));
5233+
return payload;
5234+
}
5235+
5236+
const client = makeClient(
5237+
{ ...opts, requestTimeoutMs: resolveWaitRequestTimeoutMs({ ...opts, wait: true }) },
5238+
deps,
5239+
);
5240+
const ticker = createTicker(stderrFn, opts.output === 'json' ? false : undefined);
5241+
5242+
// One shared deadline across every member (the whole point of the shared
5243+
// pool: `--timeout 600` means the invocation ends within ~600s, not
5244+
// 600s x ceil(N/concurrency)).
5245+
const deadlineMs = Date.now() + opts.timeoutSeconds * 1000;
5246+
5247+
type WaitOutcome =
5248+
| { kind: 'result'; run: RunResponse }
5249+
| { kind: 'timeout' }
5250+
| { kind: 'error'; code: string; exitCode: number };
5251+
5252+
const pollOne = async (runId: string): Promise<WaitOutcome> => {
5253+
const resolveAlternate = makeBackendWaitFallback({
5254+
client,
5255+
resolveTestId: run => run.testId,
5256+
resolveNotBefore: run => run.createdAt,
5257+
onResolved: () => undefined,
5258+
});
5259+
try {
5260+
const run = await pollRunUntilTerminal(client, runId, {
5261+
timeoutSeconds: Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000)),
5262+
sleep: deps.sleep,
5263+
onTransition: opts.verbose ? (msg: string) => stderrFn(`[verbose] ${msg}`) : undefined,
5264+
onTick: (run, elapsedMs) => {
5265+
const elapsed = Math.round(elapsedMs / 1000);
5266+
ticker.update(`Run ${run.runId}${run.status} (elapsed=${elapsed}s)`);
5267+
},
5268+
resolveAlternate,
5269+
});
5270+
return { kind: 'result', run };
5271+
} catch (err) {
5272+
if (err instanceof TimeoutError) return { kind: 'timeout' };
5273+
if (err instanceof RequestTimeoutError) throw err;
5274+
if (err instanceof ApiError) return { kind: 'error', code: err.code, exitCode: err.exitCode };
5275+
return { kind: 'error', code: 'TRANSPORT', exitCode: 10 };
5276+
}
5277+
};
5278+
5279+
const outcomes = new Map<string, WaitOutcome>();
5280+
let inFlight = 0;
5281+
let nextIdx = 0;
5282+
try {
5283+
await new Promise<void>((resolve, reject) => {
5284+
const startNext = (): void => {
5285+
while (inFlight < opts.maxConcurrency && nextIdx < opts.runIds.length) {
5286+
const runId = opts.runIds[nextIdx++]!;
5287+
inFlight++;
5288+
pollOne(runId)
5289+
.then(outcome => {
5290+
outcomes.set(runId, outcome);
5291+
inFlight--;
5292+
startNext();
5293+
if (inFlight === 0 && nextIdx >= opts.runIds.length) resolve();
5294+
})
5295+
// pollOne is total except for RequestTimeoutError (handled below).
5296+
.catch(reject);
5297+
}
5298+
};
5299+
startNext();
5300+
if (opts.runIds.length === 0) resolve();
5301+
});
5302+
} catch (fanOutErr) {
5303+
if (fanOutErr instanceof RequestTimeoutError) {
5304+
// Same contract as the batch pollers: leave stdout parseable before
5305+
// exiting 7 — every dispatched id is re-attachable in one command.
5306+
ticker.finalize('Multi-run wait — request timed out');
5307+
const partial = {
5308+
results: opts.runIds.map(runId => ({ runId, status: 'running' })),
5309+
summary: { total: opts.runIds.length },
5310+
};
5311+
out.print(partial, () => partial.results.map(r => `${r.runId} running`).join('\n'));
5312+
stderrFn(`Re-attach with: testsprite test wait ${opts.runIds.join(' ')}`);
5313+
}
5314+
throw fanOutErr;
5315+
}
5316+
ticker.finalize();
5317+
5318+
const results: CliMultiWaitResult[] = opts.runIds.map(runId => {
5319+
const outcome = outcomes.get(runId);
5320+
if (outcome === undefined || outcome.kind === 'timeout') return { runId, status: 'timeout' };
5321+
if (outcome.kind === 'error') return { runId, status: `error:${outcome.code}` };
5322+
return { runId, status: outcome.run.status, testId: outcome.run.testId };
5323+
});
5324+
const passed = results.filter(r => r.status === 'passed').length;
5325+
const timedOut = results.filter(r => r.status === 'timeout').length;
5326+
const errors = results.filter(r => r.status.startsWith('error:')).length;
5327+
const failed = results.length - passed - timedOut - errors;
5328+
const payload = {
5329+
results,
5330+
summary: { total: results.length, passed, failed, timedOut, errors },
5331+
};
5332+
out.print(payload, () =>
5333+
[
5334+
...results.map(r => `${r.runId} ${r.status}`),
5335+
'',
5336+
`${passed}/${results.length} passed, ${failed} failed/blocked, ${timedOut} timed out, ${errors} poll errors`,
5337+
].join('\n'),
5338+
);
5339+
5340+
const timedOutIds = results.filter(r => r.status === 'timeout').map(r => r.runId);
5341+
if (timedOutIds.length > 0) {
5342+
stderrFn(`Re-attach with: testsprite test wait ${timedOutIds.join(' ')}`);
5343+
}
5344+
5345+
// Worst-status exit: auth escalates (a rejected key fails every member the
5346+
// same way), then timeout/poll-error (7, resumable), then plain failure (1).
5347+
const authError = [...outcomes.values()].find(
5348+
o =>
5349+
o.kind === 'error' &&
5350+
(o.code === 'AUTH_REQUIRED' || o.code === 'AUTH_INVALID' || o.code === 'AUTH_FORBIDDEN'),
5351+
);
5352+
if (authError !== undefined && authError.kind === 'error') {
5353+
throw new CLIError(
5354+
`Multi-run wait: authentication failed (${authError.code})`,
5355+
authError.exitCode,
5356+
);
5357+
}
5358+
if (timedOut > 0 || errors > 0) {
5359+
throw new CLIError(
5360+
`Multi-run wait: ${timedOut} timed out, ${errors} poll error(s) out of ${results.length} runs`,
5361+
7,
5362+
);
5363+
}
5364+
if (failed > 0) {
5365+
throw new CLIError(`Multi-run wait: ${failed} run(s) finished non-passed`, 1);
5366+
}
5367+
return payload;
5368+
}
5369+
51885370
/**
51895371
* `test wait <run-id>` — M3.3 piece-3.
51905372
*
@@ -7963,26 +8145,52 @@ export function createTestCommand(deps: TestDeps = {}): Command {
79638145
});
79648146

79658147
test
7966-
.command('wait <run-id>')
8148+
.command('wait <run-id...>')
79678149
.description(
7968-
'Wait for a run to reach a terminal status.\n' +
8150+
'Wait for one or more runs to reach a terminal status.\n' +
8151+
'\nWith several run-ids the runs are polled concurrently under one shared\n' +
8152+
'--timeout and a {results, summary} envelope is printed (worst status wins\n' +
8153+
'the exit code), so every re-attach hint the CLI prints can be pasted as\n' +
8154+
'ONE command.\n' +
79698155
'\nExit codes:\n' +
79708156
' 0 passed\n' +
79718157
' 1 failed / blocked / cancelled\n' +
79728158
' 3 auth error\n' +
79738159
' 4 run not found\n' +
7974-
' 7 timeout — resume with: testsprite test wait <run-id>\n' +
8160+
' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
79758161
' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
79768162
'\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>',
79778163
)
79788164
.option('--timeout <s>', `max seconds to wait (1–3600, default ${DEFAULT_RUN_TIMEOUT_SECONDS})`)
8165+
.option(
8166+
'--max-concurrency <n>',
8167+
'with several run-ids, max concurrent polls (1-100, default: 10)',
8168+
)
79798169
.addHelpText('after', GLOBAL_OPTS_HINT)
7980-
.action(async (runId: string, cmdOpts: WaitFlagOpts, command: Command) => {
7981-
await runTestWait(
8170+
.action(async (runIds: string[], cmdOpts: WaitFlagOpts, command: Command) => {
8171+
// One id keeps the historical single-run path byte-identical (same
8172+
// output shape, same exit codes); two or more fan out.
8173+
if (runIds.length === 1) {
8174+
await runTestWait(
8175+
{
8176+
...resolveCommonOptions(command),
8177+
runId: runIds[0]!,
8178+
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
8179+
},
8180+
deps,
8181+
);
8182+
return;
8183+
}
8184+
const maxConcurrency = parseNumericFlag(cmdOpts.maxConcurrency, 'max-concurrency') ?? 10;
8185+
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 100) {
8186+
throw localValidationError('max-concurrency', 'must be an integer between 1 and 100');
8187+
}
8188+
await runTestWaitMany(
79828189
{
79838190
...resolveCommonOptions(command),
7984-
runId,
8191+
runIds,
79858192
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
8193+
maxConcurrency,
79868194
},
79878195
deps,
79888196
);
@@ -8142,6 +8350,7 @@ interface RunFlagOpts {
81428350

81438351
interface WaitFlagOpts {
81448352
timeout?: string;
8353+
maxConcurrency?: string;
81458354
}
81468355

81478356
interface RerunFlagOpts {

0 commit comments

Comments
 (0)