Skip to content

Commit e53257d

Browse files
authored
fix(cli): validate --request-timeout flag and de-duplicate its parser (#17)
`parseRequestTimeoutFlag` was copy-pasted byte-for-byte into five command files (auth, project, usage, init, test). Every copy silently returned `undefined` for an invalid value, so an explicit `--request-timeout 30s` (a natural "30 seconds" typo) resolved to undefined and the command ran with the default 120s deadline — the operator believed they had set a timeout but had not, with no signal. Hoist a single definition into client-factory.ts (next to resolveRequestTimeoutMs and the REQUEST_TIMEOUT_* constants) and make the flag strict: a non-numeric, zero, or negative value now throws a typed VALIDATION_ERROR (exit 5), consistent with every other validated flag (--page-size, --output, --type). Positive out-of-range values are still accepted and clamped by resolveRequestTimeoutMs, and the TESTSPRITE_REQUEST_TIMEOUT_MS env-var path stays lenient by design (a stray global env var should not hard-fail every command). Adds unit coverage for parseRequestTimeoutFlag and a subprocess regression that `--request-timeout 30s` exits 5 instead of falling back to 120s.
1 parent 8da07fb commit e53257d

8 files changed

Lines changed: 103 additions & 52 deletions

File tree

src/commands/auth.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from 'commander';
22
import {
33
emitDryRunBanner,
44
makeHttpClient,
5+
parseRequestTimeoutFlag,
56
type CommonOptions as FactoryCommonOptions,
67
} from '../lib/client-factory.js';
78
import type { ErrorCode } from '../lib/errors.js';
@@ -337,19 +338,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
337338
};
338339
}
339340

340-
/**
341-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
342-
* Returns `undefined` when the flag was not supplied (factory falls back to
343-
* the env var / default). Silently clamps out-of-range values — the
344-
* factory applies the same clamp so there is no double-clamp risk.
345-
*/
346-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
347-
if (raw === undefined) return undefined;
348-
const n = Number(raw);
349-
if (!Number.isFinite(n) || n <= 0) return undefined;
350-
return Math.round(n * 1000); // seconds → milliseconds
351-
}
352-
353341
function makeOutput(mode: OutputMode, deps: AuthDeps): Output {
354342
return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr });
355343
}

src/commands/init.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
*/
1212

1313
import { Command } from 'commander';
14-
import type { CommonOptions as FactoryCommonOptions } from '../lib/client-factory.js';
14+
import {
15+
parseRequestTimeoutFlag,
16+
type CommonOptions as FactoryCommonOptions,
17+
} from '../lib/client-factory.js';
1518
import { emitDeprecationNotice } from '../lib/deprecate.js';
1619
import { CLIError } from '../lib/errors.js';
1720
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js';
@@ -433,13 +436,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
433436
};
434437
}
435438

436-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
437-
if (raw === undefined) return undefined;
438-
const n = Number(raw);
439-
if (!Number.isFinite(n) || n <= 0) return undefined;
440-
return Math.round(n * 1000);
441-
}
442-
443439
const SETUP_DESCRIPTION =
444440
'Set up TestSprite: configure your API key and install the TestSprite agent skills for your coding agent';
445441

src/commands/project.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Command } from 'commander';
44
import {
55
emitDryRunBanner,
66
makeHttpClient,
7+
parseRequestTimeoutFlag,
78
type CommonOptions as FactoryCommonOptions,
89
} from '../lib/client-factory.js';
910
import { ApiError } from '../lib/errors.js';
@@ -531,18 +532,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
531532
};
532533
}
533534

534-
/**
535-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
536-
* Returns `undefined` when the flag was not supplied (factory falls back to
537-
* the env var / default). Silently clamps out-of-range values.
538-
*/
539-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
540-
if (raw === undefined) return undefined;
541-
const n = Number(raw);
542-
if (!Number.isFinite(n) || n <= 0) return undefined;
543-
return Math.round(n * 1000); // seconds → milliseconds
544-
}
545-
546535
function makeClient(opts: CommonOptions, deps: ProjectDeps): HttpClient {
547536
return makeHttpClient(opts, {
548537
env: deps.env,

src/commands/test.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Command } from 'commander';
66
import {
77
emitDryRunBanner,
88
makeHttpClient,
9+
parseRequestTimeoutFlag,
910
type CommonOptions as FactoryCommonOptions,
1011
} from '../lib/client-factory.js';
1112
import {
@@ -7838,18 +7839,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
78387839
};
78397840
}
78407841

7841-
/**
7842-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
7843-
* Returns `undefined` when the flag was not supplied (factory falls back to
7844-
* the env var / default). Silently clamps out-of-range values.
7845-
*/
7846-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
7847-
if (raw === undefined) return undefined;
7848-
const n = Number(raw);
7849-
if (!Number.isFinite(n) || n <= 0) return undefined;
7850-
return Math.round(n * 1000); // seconds → milliseconds
7851-
}
7852-
78537842
/** D4: headroom added on top of `--timeout` when deriving the per-request window under `--wait`. */
78547843
const WAIT_REQUEST_TIMEOUT_CUSHION_MS = 5_000;
78557844

src/commands/usage.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Command } from 'commander';
1717
import {
1818
emitDryRunBanner,
1919
makeHttpClient,
20+
parseRequestTimeoutFlag,
2021
type CommonOptions as FactoryCommonOptions,
2122
} from '../lib/client-factory.js';
2223
import { loadConfig } from '../lib/config.js';
@@ -225,13 +226,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
225226
};
226227
}
227228

228-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
229-
if (raw === undefined) return undefined;
230-
const n = Number(raw);
231-
if (!Number.isFinite(n) || n <= 0) return undefined;
232-
return Math.round(n * 1000);
233-
}
234-
235229
function makeOutput(mode: OutputMode, deps: UsageDeps): Output {
236230
return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr });
237231
}

src/lib/client-factory.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
assertValidEndpointUrl,
66
emitDryRunBanner,
77
makeHttpClient,
8+
parseRequestTimeoutFlag,
89
resetDryRunBannerForTesting,
910
resolveRequestTimeoutMs,
1011
} from './client-factory.js';
@@ -213,6 +214,45 @@ describe('resolveRequestTimeoutMs', () => {
213214
});
214215
});
215216

217+
// ---------------------------------------------------------------------------
218+
// parseRequestTimeoutFlag — strict flag parsing (seconds → ms)
219+
// ---------------------------------------------------------------------------
220+
221+
describe('parseRequestTimeoutFlag', () => {
222+
it('returns undefined when the flag is omitted (factory falls back to env/default)', () => {
223+
expect(parseRequestTimeoutFlag(undefined)).toBeUndefined();
224+
});
225+
226+
it('converts a positive number of seconds to milliseconds', () => {
227+
expect(parseRequestTimeoutFlag('30')).toBe(30_000);
228+
expect(parseRequestTimeoutFlag('1')).toBe(1_000);
229+
expect(parseRequestTimeoutFlag('2.5')).toBe(2_500);
230+
});
231+
232+
it('does NOT reject positive out-of-range values — resolveRequestTimeoutMs clamps them', () => {
233+
// 700s is above the 600s cap, but parsing succeeds; the clamp lives in
234+
// resolveRequestTimeoutMs so a large script-supplied value still works.
235+
expect(parseRequestTimeoutFlag('700')).toBe(700_000);
236+
});
237+
238+
it.each(['abc', '30s', '0', '-5', 'NaN', 'Infinity', ''])(
239+
'throws a VALIDATION_ERROR (exit 5) on the invalid flag value %j',
240+
bad => {
241+
let caught: unknown;
242+
try {
243+
parseRequestTimeoutFlag(bad);
244+
} catch (err) {
245+
caught = err;
246+
}
247+
expect(caught).toBeInstanceOf(ApiError);
248+
const apiErr = caught as ApiError;
249+
expect(apiErr.code).toBe('VALIDATION_ERROR');
250+
expect(apiErr.exitCode).toBe(5);
251+
expect(apiErr.nextAction).toContain('request-timeout');
252+
},
253+
);
254+
});
255+
216256
// ---------------------------------------------------------------------------
217257
// makeHttpClient — requestTimeoutMs propagation
218258
// ---------------------------------------------------------------------------

src/lib/client-factory.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,43 @@ export function assertValidEndpointUrl(rawUrl: string): void {
180180
}
181181
}
182182

183+
/**
184+
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
185+
*
186+
* Returns `undefined` when the flag was omitted (the factory then falls back to
187+
* the `TESTSPRITE_REQUEST_TIMEOUT_MS` env var, else the 120s default).
188+
*
189+
* A supplied-but-invalid value (non-numeric, zero, or negative) throws a typed
190+
* VALIDATION_ERROR (exit 5) rather than being silently dropped. An explicit
191+
* `--request-timeout 30s` typo previously resolved to `undefined` and the
192+
* command ran with the default 120s deadline — the operator believed they had
193+
* set a timeout but had not, with no signal. Failing loudly here is consistent
194+
* with every other validated flag (`--page-size`, `--output`, `--type`).
195+
*
196+
* Out-of-range but positive values are intentionally NOT rejected — they flow
197+
* through to {@link resolveRequestTimeoutMs}, which clamps to
198+
* `[REQUEST_TIMEOUT_MIN_MS, REQUEST_TIMEOUT_MAX_MS]`. The env-var path stays
199+
* lenient by design (a stray global env var should not hard-fail every
200+
* command); only the explicit per-invocation flag is strict.
201+
*
202+
* This single definition replaces five byte-identical copies that previously
203+
* lived in `auth`, `project`, `usage`, `init`, and `test` — drift between them
204+
* would have silently changed timeout behaviour depending on the command.
205+
*/
206+
export function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
207+
if (raw === undefined) return undefined;
208+
const n = Number(raw);
209+
if (!Number.isFinite(n) || n <= 0) {
210+
// Surface the offending value in the message (same as assertValidEndpointUrl)
211+
// so the operator sees exactly what they typed.
212+
throw localValidationError(
213+
'request-timeout',
214+
`"${raw}" is not valid — must be a positive number of seconds`,
215+
);
216+
}
217+
return Math.round(n * 1000); // seconds → milliseconds
218+
}
219+
183220
export function makeHttpClient(opts: CommonOptions, deps: ClientFactoryDeps = {}): HttpClient {
184221
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
185222
const env = deps.env ?? process.env;

test/cli.subprocess.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,24 @@ describe('project list subprocess', () => {
499499
const parsed = JSON.parse(result.stderr) as { error: { code: string } };
500500
expect(parsed.error.code).toBe('VALIDATION_ERROR');
501501
}, 30_000);
502+
503+
it('--request-timeout 30s exits 5 (VALIDATION_ERROR), not a silent fallback to 120s', async () => {
504+
// Previously an invalid flag value resolved to `undefined` and the command
505+
// silently ran with the default 120s deadline — the operator believed they
506+
// had set a timeout but had not. Now the explicit flag is validated like
507+
// every other flag.
508+
const result = await runCli(
509+
['--output', 'json', '--request-timeout', '30s', 'project', 'list'],
510+
{
511+
TESTSPRITE_API_KEY: 'sk-subproc',
512+
TESTSPRITE_API_URL: baseUrl,
513+
},
514+
);
515+
expect(result.exitCode).toBe(5);
516+
const parsed = JSON.parse(result.stderr) as { error: { code: string; nextAction: string } };
517+
expect(parsed.error.code).toBe('VALIDATION_ERROR');
518+
expect(parsed.error.nextAction).toContain('request-timeout');
519+
}, 30_000);
502520
});
503521

504522
describe('malformed --endpoint-url is rejected (exit 5), not retried as a network error', () => {

0 commit comments

Comments
 (0)