Skip to content

Commit dce194c

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

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
@@ -34,6 +34,7 @@ import {
3434
runPlanPut,
3535
runResult,
3636
runSteps,
37+
runTestWaitMany,
3738
runUpdate,
3839
} from './test.js';
3940

@@ -2721,6 +2722,124 @@ describe('runSteps', () => {
27212722
});
27222723
});
27232724

2725+
describe('runTestWaitMany', () => {
2726+
const terminalRun = (runId: string, status: string) => ({
2727+
runId,
2728+
testId: `test_of_${runId}`,
2729+
projectId: 'project_alice',
2730+
userId: 'u1',
2731+
status,
2732+
source: 'cli',
2733+
createdAt: '2026-06-01T10:00:00.000Z',
2734+
startedAt: '2026-06-01T10:00:01.000Z',
2735+
finishedAt: '2026-06-01T10:00:30.000Z',
2736+
codeVersion: 'v1',
2737+
targetUrl: 'https://example.com',
2738+
createdFrom: null,
2739+
failedStepIndex: null,
2740+
failureKind: null,
2741+
error: null,
2742+
videoUrl: null,
2743+
stepSummary: { total: 1, completed: 1, passedCount: 1, failedCount: 0 },
2744+
});
2745+
2746+
it('polls every run, keeps input order, and exits 1 when one finished non-passed', async () => {
2747+
const { credentialsPath } = makeCreds();
2748+
const fetchImpl = makeFetch(url => ({
2749+
body: url.includes('run_bad')
2750+
? terminalRun('run_bad', 'failed')
2751+
: terminalRun('run_ok', 'passed'),
2752+
}));
2753+
const out: string[] = [];
2754+
const rejection = await runTestWaitMany(
2755+
{
2756+
profile: 'default',
2757+
output: 'json',
2758+
debug: false,
2759+
runIds: ['run_ok', 'run_bad'],
2760+
timeoutSeconds: 30,
2761+
maxConcurrency: 2,
2762+
},
2763+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2764+
).catch((error: unknown) => error);
2765+
expect(rejection).toMatchObject({ exitCode: 1 });
2766+
const payload = JSON.parse(out.join('')) as {
2767+
results: Array<{ runId: string; status: string }>;
2768+
summary: { passed: number; failed: number };
2769+
};
2770+
expect(payload.results.map(row => row.runId)).toEqual(['run_ok', 'run_bad']);
2771+
expect(payload.summary).toMatchObject({ passed: 1, failed: 1 });
2772+
});
2773+
2774+
it('a member whose poll errors is captured as error:<CODE> and the others survive (exit 7)', async () => {
2775+
const { credentialsPath } = makeCreds();
2776+
const fetchImpl = makeFetch(url => {
2777+
if (url.includes('run_gone')) {
2778+
return {
2779+
status: 404,
2780+
body: {
2781+
error: {
2782+
code: 'NOT_FOUND',
2783+
message: 'no such run',
2784+
nextAction: 'check the id',
2785+
requestId: 'req_x',
2786+
details: {},
2787+
},
2788+
},
2789+
};
2790+
}
2791+
return { body: terminalRun('run_ok', 'passed') };
2792+
});
2793+
const out: string[] = [];
2794+
const rejection = await runTestWaitMany(
2795+
{
2796+
profile: 'default',
2797+
output: 'json',
2798+
debug: false,
2799+
runIds: ['run_ok', 'run_gone'],
2800+
timeoutSeconds: 30,
2801+
maxConcurrency: 2,
2802+
},
2803+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2804+
).catch((error: unknown) => error);
2805+
expect(rejection).toMatchObject({ exitCode: 7 });
2806+
const payload = JSON.parse(out.join('')) as {
2807+
results: Array<{ runId: string; status: string }>;
2808+
};
2809+
// The failing member did not abort the pool: the passed verdict survived.
2810+
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
2811+
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
2812+
});
2813+
2814+
it('an auth error escalates the exit code to 3', async () => {
2815+
const { credentialsPath } = makeCreds();
2816+
const fetchImpl = makeFetch(() => ({
2817+
status: 401,
2818+
body: {
2819+
error: {
2820+
code: 'AUTH_INVALID',
2821+
message: 'bad key',
2822+
nextAction: 'run setup',
2823+
requestId: 'req_y',
2824+
details: {},
2825+
},
2826+
},
2827+
}));
2828+
const rejection = await runTestWaitMany(
2829+
{
2830+
profile: 'default',
2831+
output: 'json',
2832+
debug: false,
2833+
runIds: ['run_a', 'run_b'],
2834+
timeoutSeconds: 30,
2835+
maxConcurrency: 2,
2836+
},
2837+
{ credentialsPath, fetchImpl, stdout: () => undefined },
2838+
).catch((error: unknown) => error);
2839+
expect(rejection).toMatchObject({ exitCode: 3 });
2840+
});
2841+
});
2842+
27242843
describe('runResult', () => {
27252844
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
27262845
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 215 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4919,6 +4919,188 @@ export async function runTestRun(
49194919
return finalRun;
49204920
}
49214921

4922+
/** One row of the `test wait <run-id...>` multi-run payload. */
4923+
export interface CliMultiWaitResult {
4924+
runId: string;
4925+
/** Terminal run status, or 'timeout', or 'error:<CODE>' when the poll failed. */
4926+
status: string;
4927+
/** Test the run belongs to, when the poll observed it. */
4928+
testId?: string;
4929+
}
4930+
4931+
export interface RunTestWaitManyOptions extends CommonOptions {
4932+
runIds: string[];
4933+
timeoutSeconds: number;
4934+
maxConcurrency: number;
4935+
}
4936+
4937+
/**
4938+
* `test wait <run-id...>` with two or more ids: attach to N already-dispatched
4939+
* runs in ONE invocation. This closes the loop the CLI itself opens: every
4940+
* batch/closure timeout prints one `testsprite test wait <runId>` hint PER
4941+
* member, which previously meant N sequential blocking invocations. The runs
4942+
* are polled concurrently under a bounded pool with ONE shared deadline
4943+
* (`--timeout` bounds the whole invocation, not each member), each member's
4944+
* poll is total (a transient error on one run never discards the others), and
4945+
* the exit code is the worst status across members: auth errors escalate to
4946+
* exit 3, any timeout or poll error exits 7, any non-passed terminal exits 1.
4947+
* Distinct from a run journal (issue #80): no persistence, just N known ids.
4948+
*/
4949+
export async function runTestWaitMany(
4950+
opts: RunTestWaitManyOptions,
4951+
deps: TestDeps = {},
4952+
): Promise<{ results: CliMultiWaitResult[]; summary: Record<string, number> }> {
4953+
const out = makeOutput(opts.output, deps);
4954+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
4955+
4956+
if (opts.dryRun) {
4957+
emitDryRunBanner(stderrFn);
4958+
const results: CliMultiWaitResult[] = opts.runIds.map(runId => ({
4959+
runId,
4960+
status: 'passed',
4961+
}));
4962+
const payload = {
4963+
results,
4964+
summary: { total: results.length, passed: results.length, failed: 0, timedOut: 0, errors: 0 },
4965+
};
4966+
out.print(payload, () => results.map(r => `${r.runId} ${r.status}`).join('\n'));
4967+
return payload;
4968+
}
4969+
4970+
const client = makeClient(
4971+
{ ...opts, requestTimeoutMs: resolveWaitRequestTimeoutMs({ ...opts, wait: true }) },
4972+
deps,
4973+
);
4974+
const ticker = createTicker(stderrFn, opts.output === 'json' ? false : undefined);
4975+
4976+
// One shared deadline across every member (the whole point of the shared
4977+
// pool: `--timeout 600` means the invocation ends within ~600s, not
4978+
// 600s x ceil(N/concurrency)).
4979+
const deadlineMs = Date.now() + opts.timeoutSeconds * 1000;
4980+
4981+
type WaitOutcome =
4982+
| { kind: 'result'; run: RunResponse }
4983+
| { kind: 'timeout' }
4984+
| { kind: 'error'; code: string; exitCode: number };
4985+
4986+
const pollOne = async (runId: string): Promise<WaitOutcome> => {
4987+
const resolveAlternate = makeBackendWaitFallback({
4988+
client,
4989+
resolveTestId: run => run.testId,
4990+
resolveNotBefore: run => run.createdAt,
4991+
onResolved: () => undefined,
4992+
});
4993+
try {
4994+
const run = await pollRunUntilTerminal(client, runId, {
4995+
timeoutSeconds: Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000)),
4996+
sleep: deps.sleep,
4997+
onTransition: opts.verbose ? (msg: string) => stderrFn(`[verbose] ${msg}`) : undefined,
4998+
onTick: (run, elapsedMs) => {
4999+
const elapsed = Math.round(elapsedMs / 1000);
5000+
ticker.update(`Run ${run.runId}${run.status} (elapsed=${elapsed}s)`);
5001+
},
5002+
resolveAlternate,
5003+
});
5004+
return { kind: 'result', run };
5005+
} catch (err) {
5006+
if (err instanceof TimeoutError) return { kind: 'timeout' };
5007+
if (err instanceof RequestTimeoutError) throw err;
5008+
if (err instanceof ApiError) return { kind: 'error', code: err.code, exitCode: err.exitCode };
5009+
return { kind: 'error', code: 'TRANSPORT', exitCode: 10 };
5010+
}
5011+
};
5012+
5013+
const outcomes = new Map<string, WaitOutcome>();
5014+
let inFlight = 0;
5015+
let nextIdx = 0;
5016+
try {
5017+
await new Promise<void>((resolve, reject) => {
5018+
const startNext = (): void => {
5019+
while (inFlight < opts.maxConcurrency && nextIdx < opts.runIds.length) {
5020+
const runId = opts.runIds[nextIdx++]!;
5021+
inFlight++;
5022+
pollOne(runId)
5023+
.then(outcome => {
5024+
outcomes.set(runId, outcome);
5025+
inFlight--;
5026+
startNext();
5027+
if (inFlight === 0 && nextIdx >= opts.runIds.length) resolve();
5028+
})
5029+
// pollOne is total except for RequestTimeoutError (handled below).
5030+
.catch(reject);
5031+
}
5032+
};
5033+
startNext();
5034+
if (opts.runIds.length === 0) resolve();
5035+
});
5036+
} catch (fanOutErr) {
5037+
if (fanOutErr instanceof RequestTimeoutError) {
5038+
// Same contract as the batch pollers: leave stdout parseable before
5039+
// exiting 7 — every dispatched id is re-attachable in one command.
5040+
ticker.finalize('Multi-run wait — request timed out');
5041+
const partial = {
5042+
results: opts.runIds.map(runId => ({ runId, status: 'running' })),
5043+
summary: { total: opts.runIds.length },
5044+
};
5045+
out.print(partial, () => partial.results.map(r => `${r.runId} running`).join('\n'));
5046+
stderrFn(`Re-attach with: testsprite test wait ${opts.runIds.join(' ')}`);
5047+
}
5048+
throw fanOutErr;
5049+
}
5050+
ticker.finalize();
5051+
5052+
const results: CliMultiWaitResult[] = opts.runIds.map(runId => {
5053+
const outcome = outcomes.get(runId);
5054+
if (outcome === undefined || outcome.kind === 'timeout') return { runId, status: 'timeout' };
5055+
if (outcome.kind === 'error') return { runId, status: `error:${outcome.code}` };
5056+
return { runId, status: outcome.run.status, testId: outcome.run.testId };
5057+
});
5058+
const passed = results.filter(r => r.status === 'passed').length;
5059+
const timedOut = results.filter(r => r.status === 'timeout').length;
5060+
const errors = results.filter(r => r.status.startsWith('error:')).length;
5061+
const failed = results.length - passed - timedOut - errors;
5062+
const payload = {
5063+
results,
5064+
summary: { total: results.length, passed, failed, timedOut, errors },
5065+
};
5066+
out.print(payload, () =>
5067+
[
5068+
...results.map(r => `${r.runId} ${r.status}`),
5069+
'',
5070+
`${passed}/${results.length} passed, ${failed} failed/blocked, ${timedOut} timed out, ${errors} poll errors`,
5071+
].join('\n'),
5072+
);
5073+
5074+
const timedOutIds = results.filter(r => r.status === 'timeout').map(r => r.runId);
5075+
if (timedOutIds.length > 0) {
5076+
stderrFn(`Re-attach with: testsprite test wait ${timedOutIds.join(' ')}`);
5077+
}
5078+
5079+
// Worst-status exit: auth escalates (a rejected key fails every member the
5080+
// same way), then timeout/poll-error (7, resumable), then plain failure (1).
5081+
const authError = [...outcomes.values()].find(
5082+
o =>
5083+
o.kind === 'error' &&
5084+
(o.code === 'AUTH_REQUIRED' || o.code === 'AUTH_INVALID' || o.code === 'AUTH_FORBIDDEN'),
5085+
);
5086+
if (authError !== undefined && authError.kind === 'error') {
5087+
throw new CLIError(
5088+
`Multi-run wait: authentication failed (${authError.code})`,
5089+
authError.exitCode,
5090+
);
5091+
}
5092+
if (timedOut > 0 || errors > 0) {
5093+
throw new CLIError(
5094+
`Multi-run wait: ${timedOut} timed out, ${errors} poll error(s) out of ${results.length} runs`,
5095+
7,
5096+
);
5097+
}
5098+
if (failed > 0) {
5099+
throw new CLIError(`Multi-run wait: ${failed} run(s) finished non-passed`, 1);
5100+
}
5101+
return payload;
5102+
}
5103+
49225104
/**
49235105
* `test wait <run-id>` — M3.3 piece-3.
49245106
*
@@ -7515,26 +7697,52 @@ export function createTestCommand(deps: TestDeps = {}): Command {
75157697
});
75167698

75177699
test
7518-
.command('wait <run-id>')
7700+
.command('wait <run-id...>')
75197701
.description(
7520-
'Wait for a run to reach a terminal status.\n' +
7702+
'Wait for one or more runs to reach a terminal status.\n' +
7703+
'\nWith several run-ids the runs are polled concurrently under one shared\n' +
7704+
'--timeout and a {results, summary} envelope is printed (worst status wins\n' +
7705+
'the exit code), so every re-attach hint the CLI prints can be pasted as\n' +
7706+
'ONE command.\n' +
75217707
'\nExit codes:\n' +
75227708
' 0 passed\n' +
75237709
' 1 failed / blocked / cancelled\n' +
75247710
' 3 auth error\n' +
75257711
' 4 run not found\n' +
7526-
' 7 timeout — resume with: testsprite test wait <run-id>\n' +
7712+
' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
75277713
' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
75287714
'\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>',
75297715
)
75307716
.option('--timeout <s>', `max seconds to wait (1–3600, default ${DEFAULT_RUN_TIMEOUT_SECONDS})`)
7717+
.option(
7718+
'--max-concurrency <n>',
7719+
'with several run-ids, max concurrent polls (1-100, default: 10)',
7720+
)
75317721
.addHelpText('after', GLOBAL_OPTS_HINT)
7532-
.action(async (runId: string, cmdOpts: WaitFlagOpts, command: Command) => {
7533-
await runTestWait(
7722+
.action(async (runIds: string[], cmdOpts: WaitFlagOpts, command: Command) => {
7723+
// One id keeps the historical single-run path byte-identical (same
7724+
// output shape, same exit codes); two or more fan out.
7725+
if (runIds.length === 1) {
7726+
await runTestWait(
7727+
{
7728+
...resolveCommonOptions(command),
7729+
runId: runIds[0]!,
7730+
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
7731+
},
7732+
deps,
7733+
);
7734+
return;
7735+
}
7736+
const maxConcurrency = parseNumericFlag(cmdOpts.maxConcurrency, 'max-concurrency') ?? 10;
7737+
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 100) {
7738+
throw localValidationError('max-concurrency', 'must be an integer between 1 and 100');
7739+
}
7740+
await runTestWaitMany(
75347741
{
75357742
...resolveCommonOptions(command),
7536-
runId,
7743+
runIds,
75377744
timeoutSeconds: parseTimeoutFlag(cmdOpts.timeout, 'timeout'),
7745+
maxConcurrency,
75387746
},
75397747
deps,
75407748
);
@@ -7669,6 +7877,7 @@ interface RunFlagOpts {
76697877

76707878
interface WaitFlagOpts {
76717879
timeout?: string;
7880+
maxConcurrency?: string;
76727881
}
76737882

76747883
interface RerunFlagOpts {

0 commit comments

Comments
 (0)