Skip to content

Commit 0d56c43

Browse files
committed
fix(cli): validate --request-timeout flag and de-duplicate its parser
`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 15e95de commit 0d56c43

8 files changed

Lines changed: 98 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';
@@ -327,19 +328,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
327328
};
328329
}
329330

330-
/**
331-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
332-
* Returns `undefined` when the flag was not supplied (factory falls back to
333-
* the env var / default). Silently clamps out-of-range values — the
334-
* factory applies the same clamp so there is no double-clamp risk.
335-
*/
336-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
337-
if (raw === undefined) return undefined;
338-
const n = Number(raw);
339-
if (!Number.isFinite(n) || n <= 0) return undefined;
340-
return Math.round(n * 1000); // seconds → milliseconds
341-
}
342-
343331
function makeOutput(mode: OutputMode, deps: AuthDeps): Output {
344332
return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr });
345333
}

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 } from '../lib/output.js';
@@ -400,13 +403,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
400403
};
401404
}
402405

403-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
404-
if (raw === undefined) return undefined;
405-
const n = Number(raw);
406-
if (!Number.isFinite(n) || n <= 0) return undefined;
407-
return Math.round(n * 1000);
408-
}
409-
410406
const SETUP_DESCRIPTION =
411407
'Set up TestSprite: configure your API key and install the verification skill for your coding agent';
412408

src/commands/project.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
33
import { Command } from 'commander';
44
import {
55
makeHttpClient,
6+
parseRequestTimeoutFlag,
67
type CommonOptions as FactoryCommonOptions,
78
} from '../lib/client-factory.js';
89
import { ApiError } from '../lib/errors.js';
@@ -503,18 +504,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
503504
};
504505
}
505506

506-
/**
507-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
508-
* Returns `undefined` when the flag was not supplied (factory falls back to
509-
* the env var / default). Silently clamps out-of-range values.
510-
*/
511-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
512-
if (raw === undefined) return undefined;
513-
const n = Number(raw);
514-
if (!Number.isFinite(n) || n <= 0) return undefined;
515-
return Math.round(n * 1000); // seconds → milliseconds
516-
}
517-
518507
function makeClient(opts: CommonOptions, deps: ProjectDeps): HttpClient {
519508
return makeHttpClient(opts, {
520509
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 {
@@ -7776,18 +7777,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
77767777
};
77777778
}
77787779

7779-
/**
7780-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
7781-
* Returns `undefined` when the flag was not supplied (factory falls back to
7782-
* the env var / default). Silently clamps out-of-range values.
7783-
*/
7784-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
7785-
if (raw === undefined) return undefined;
7786-
const n = Number(raw);
7787-
if (!Number.isFinite(n) || n <= 0) return undefined;
7788-
return Math.round(n * 1000); // seconds → milliseconds
7789-
}
7790-
77917780
/** D4: headroom added on top of `--timeout` when deriving the per-request window under `--wait`. */
77927781
const WAIT_REQUEST_TIMEOUT_CUSHION_MS = 5_000;
77937782

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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,38 @@ 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+
throw localValidationError('request-timeout', 'must be a positive number of seconds');
211+
}
212+
return Math.round(n * 1000); // seconds → milliseconds
213+
}
214+
183215
export function makeHttpClient(opts: CommonOptions, deps: ClientFactoryDeps = {}): HttpClient {
184216
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
185217
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
@@ -497,6 +497,24 @@ describe('project list subprocess', () => {
497497
const parsed = JSON.parse(result.stderr) as { error: { code: string } };
498498
expect(parsed.error.code).toBe('VALIDATION_ERROR');
499499
}, 30_000);
500+
501+
it('--request-timeout 30s exits 5 (VALIDATION_ERROR), not a silent fallback to 120s', async () => {
502+
// Previously an invalid flag value resolved to `undefined` and the command
503+
// silently ran with the default 120s deadline — the operator believed they
504+
// had set a timeout but had not. Now the explicit flag is validated like
505+
// every other flag.
506+
const result = await runCli(
507+
['--output', 'json', '--request-timeout', '30s', 'project', 'list'],
508+
{
509+
TESTSPRITE_API_KEY: 'sk-subproc',
510+
TESTSPRITE_API_URL: baseUrl,
511+
},
512+
);
513+
expect(result.exitCode).toBe(5);
514+
const parsed = JSON.parse(result.stderr) as { error: { code: string; nextAction: string } };
515+
expect(parsed.error.code).toBe('VALIDATION_ERROR');
516+
expect(parsed.error.nextAction).toContain('request-timeout');
517+
}, 30_000);
500518
});
501519

502520
describe('a malformed --profile is rejected (exit 5), not silently corrupting credentials', () => {

0 commit comments

Comments
 (0)