Skip to content

Commit 8ac2ff0

Browse files
authored
feat(test): make "test wait" variadic — attach to several runs in one invocation (#178)
* feat(test): make "test wait" variadic — attach to several runs in one invocation * fix(wait): honor the shared deadline for queued members and hint only resumable runs
1 parent 108a913 commit 8ac2ff0

3 files changed

Lines changed: 404 additions & 10 deletions

File tree

src/commands/test.test.ts

Lines changed: 155 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,160 @@ 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 errs: string[] = [];
3116+
const rejection = await runTestWaitMany(
3117+
{
3118+
profile: 'default',
3119+
output: 'json',
3120+
debug: false,
3121+
runIds: ['run_ok', 'run_gone'],
3122+
timeoutSeconds: 30,
3123+
maxConcurrency: 2,
3124+
},
3125+
{
3126+
credentialsPath,
3127+
fetchImpl,
3128+
stdout: line => out.push(line),
3129+
stderr: line => errs.push(line),
3130+
},
3131+
).catch((error: unknown) => error);
3132+
expect(rejection).toMatchObject({ exitCode: 7 });
3133+
const payload = JSON.parse(out.join('')) as {
3134+
results: Array<{ runId: string; status: string }>;
3135+
};
3136+
// The failing member did not abort the pool: the passed verdict survived.
3137+
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
3138+
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
3139+
// The re-attach hint names the errored member (resumable) but NOT the
3140+
// already-terminal passed one.
3141+
const hint = errs.find(line => line.includes('Re-attach with:'));
3142+
expect(hint).toContain('run_gone');
3143+
expect(hint).not.toContain('run_ok');
3144+
});
3145+
3146+
it('members dequeued after the shared deadline are not granted extra poll time', async () => {
3147+
const { credentialsPath } = makeCreds();
3148+
let fetches = 0;
3149+
const fetchImpl = makeFetch(() => {
3150+
fetches += 1;
3151+
return { body: terminalRun('run_any', 'passed') };
3152+
});
3153+
// timeoutSeconds 0: the shared deadline is already in the past when the
3154+
// pool starts, so every member must resolve to timeout WITHOUT polling
3155+
// (previously each dequeued member was granted a fresh 1s minimum).
3156+
const rejection = await runTestWaitMany(
3157+
{
3158+
profile: 'default',
3159+
output: 'json',
3160+
debug: false,
3161+
runIds: ['run_a', 'run_b', 'run_c'],
3162+
timeoutSeconds: 0,
3163+
maxConcurrency: 1,
3164+
},
3165+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
3166+
).catch((error: unknown) => error);
3167+
expect(rejection).toMatchObject({ exitCode: 7 });
3168+
expect(fetches).toBe(0);
3169+
});
3170+
3171+
it('an auth error escalates the exit code to 3', async () => {
3172+
const { credentialsPath } = makeCreds();
3173+
const fetchImpl = makeFetch(() => ({
3174+
status: 401,
3175+
body: {
3176+
error: {
3177+
code: 'AUTH_INVALID',
3178+
message: 'bad key',
3179+
nextAction: 'run setup',
3180+
requestId: 'req_y',
3181+
details: {},
3182+
},
3183+
},
3184+
}));
3185+
const rejection = await runTestWaitMany(
3186+
{
3187+
profile: 'default',
3188+
output: 'json',
3189+
debug: false,
3190+
runIds: ['run_a', 'run_b'],
3191+
timeoutSeconds: 30,
3192+
maxConcurrency: 2,
3193+
},
3194+
{ credentialsPath, fetchImpl, stdout: () => undefined },
3195+
).catch((error: unknown) => error);
3196+
expect(rejection).toMatchObject({ exitCode: 3 });
3197+
});
3198+
});
3199+
30453200
describe('runResult', () => {
30463201
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
30473202
const { credentialsPath } = makeCreds();

0 commit comments

Comments
 (0)