Skip to content

Commit efc097e

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 18f6e6e commit efc097e

8 files changed

Lines changed: 100 additions & 53 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 {
@@ -7685,18 +7686,6 @@ function resolveCommonOptions(command: Command): CommonOptions {
76857686
};
76867687
}
76877688

7688-
/**
7689-
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
7690-
* Returns `undefined` when the flag was not supplied (factory falls back to
7691-
* the env var / default). Silently clamps out-of-range values.
7692-
*/
7693-
function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
7694-
if (raw === undefined) return undefined;
7695-
const n = Number(raw);
7696-
if (!Number.isFinite(n) || n <= 0) return undefined;
7697-
return Math.round(n * 1000); // seconds → milliseconds
7698-
}
7699-
77007689
/** D4: headroom added on top of `--timeout` when deriving the per-request window under `--wait`. */
77017690
const WAIT_REQUEST_TIMEOUT_CUSHION_MS = 5_000;
77027691

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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DRY_RUN_BANNER,
55
emitDryRunBanner,
66
makeHttpClient,
7+
parseRequestTimeoutFlag,
78
resetDryRunBannerForTesting,
89
resolveRequestTimeoutMs,
910
} from './client-factory.js';
@@ -12,6 +13,7 @@ import {
1213
REQUEST_TIMEOUT_MAX_MS,
1314
REQUEST_TIMEOUT_MIN_MS,
1415
} from './http.js';
16+
import { ApiError } from './errors.js';
1517

1618
const NO_CREDS_PATH = '/tmp/testsprite-cli-test-no-such-file-1234.ini';
1719

@@ -211,6 +213,45 @@ describe('resolveRequestTimeoutMs', () => {
211213
});
212214
});
213215

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

src/lib/client-factory.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616
import { loadConfig } from './config.js';
1717
import { defaultCredentialsPath } from './credentials.js';
18-
import { ApiError } from './errors.js';
18+
import { ApiError, localValidationError } from './errors.js';
1919
import { facadeBaseUrl } from './facade.js';
2020
import type { DebugEvent, FetchImpl } from './http.js';
2121
import {
@@ -139,6 +139,38 @@ function clampRequestTimeout(ms: number): number {
139139
return Math.min(Math.max(Math.round(ms), REQUEST_TIMEOUT_MIN_MS), REQUEST_TIMEOUT_MAX_MS);
140140
}
141141

142+
/**
143+
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
144+
*
145+
* Returns `undefined` when the flag was omitted (the factory then falls back to
146+
* the `TESTSPRITE_REQUEST_TIMEOUT_MS` env var, else the 120s default).
147+
*
148+
* A supplied-but-invalid value (non-numeric, zero, or negative) throws a typed
149+
* VALIDATION_ERROR (exit 5) rather than being silently dropped. An explicit
150+
* `--request-timeout 30s` typo previously resolved to `undefined` and the
151+
* command ran with the default 120s deadline — the operator believed they had
152+
* set a timeout but had not, with no signal. Failing loudly here is consistent
153+
* with every other validated flag (`--page-size`, `--output`, `--type`).
154+
*
155+
* Out-of-range but positive values are intentionally NOT rejected — they flow
156+
* through to {@link resolveRequestTimeoutMs}, which clamps to
157+
* `[REQUEST_TIMEOUT_MIN_MS, REQUEST_TIMEOUT_MAX_MS]`. The env-var path stays
158+
* lenient by design (a stray global env var should not hard-fail every
159+
* command); only the explicit per-invocation flag is strict.
160+
*
161+
* This single definition replaces five byte-identical copies that previously
162+
* lived in `auth`, `project`, `usage`, `init`, and `test` — drift between them
163+
* would have silently changed timeout behaviour depending on the command.
164+
*/
165+
export function parseRequestTimeoutFlag(raw: string | undefined): number | undefined {
166+
if (raw === undefined) return undefined;
167+
const n = Number(raw);
168+
if (!Number.isFinite(n) || n <= 0) {
169+
throw localValidationError('request-timeout', 'must be a positive number of seconds');
170+
}
171+
return Math.round(n * 1000); // seconds → milliseconds
172+
}
173+
142174
export function makeHttpClient(opts: CommonOptions, deps: ClientFactoryDeps = {}): HttpClient {
143175
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
144176
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('project get subprocess', () => {

0 commit comments

Comments
 (0)