From 0a1ef36f8b449ca2e42f5042fdd3ed7b7c0a6809 Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 Date: Mon, 6 Jul 2026 23:33:42 +0530 Subject: [PATCH 1/2] fix(config): treat empty env vars as unset in loadConfig --- src/commands/auth.test.ts | 15 +++++++++++++++ src/commands/auth.ts | 7 ++++--- src/commands/init.ts | 3 ++- src/lib/config.test.ts | 22 ++++++++++++++++++++++ src/lib/config.ts | 15 +++++++++++++-- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/commands/auth.test.ts b/src/commands/auth.test.ts index 1605f58..c7d669c 100644 --- a/src/commands/auth.test.ts +++ b/src/commands/auth.test.ts @@ -850,6 +850,21 @@ describe('runWhoami', () => { expect(printed).toEqual(sampleMe); }); + it('dry-run: whitespace-only TESTSPRITE_API_URL falls through to prod default endpoint', async () => { + const { capture, deps } = makeCapture(); + await runWhoami( + { profile: 'default', output: 'text', debug: false, dryRun: true }, + { + ...deps, + env: { TESTSPRITE_API_URL: ' ' }, + credentialsPath, + fetchImpl: makeFetch(meResponse()), + }, + ); + const out = capture.stdout.join('\n'); + expect(out).toContain('endpoint: https://api.testsprite.com'); + }); + it('L1788: text output includes the resolved endpoint URL', async () => { writeProfile( 'default', diff --git a/src/commands/auth.ts b/src/commands/auth.ts index bf2eb88..59cd43f 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -17,7 +17,7 @@ import { readProfile, writeProfile, } from '../lib/credentials.js'; -import { loadConfig } from '../lib/config.js'; +import { loadConfig, normalizeEnvVar } from '../lib/config.js'; import { emitDeprecationNotice } from '../lib/deprecate.js'; import type { OutputMode } from '../lib/output.js'; import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js'; @@ -85,7 +85,7 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}): // treated as unset. Without this, `''` (e.g. `export TESTSPRITE_API_URL=` in a // shell profile) is non-nullish and would short-circuit the `??` chains below to // an empty endpoint instead of falling through to the profile / prod default. - const envApiUrl = env.TESTSPRITE_API_URL?.trim() || undefined; + const envApiUrl = normalizeEnvVar(env.TESTSPRITE_API_URL); // Dry-run: do not prompt, do not read env, do not write credentials. // Print the canned success shape so an agent sees exactly the JSON it @@ -208,7 +208,8 @@ export async function runWhoami(opts: CommonOptions, deps: AuthDeps = {}): Promi // displayed URL always matches where requests actually go (dogfood L1788). let resolvedEndpoint: string; if (opts.dryRun) { - resolvedEndpoint = opts.endpointUrl ?? env.TESTSPRITE_API_URL ?? 'https://api.testsprite.com'; + resolvedEndpoint = + opts.endpointUrl ?? normalizeEnvVar(env.TESTSPRITE_API_URL) ?? 'https://api.testsprite.com'; } else { const credentialsPath = deps.credentialsPath ?? defaultCredentialsPath(); const config = loadConfig({ diff --git a/src/commands/init.ts b/src/commands/init.ts index e01215a..aff0644 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,6 +15,7 @@ import { parseRequestTimeoutFlag, type CommonOptions as FactoryCommonOptions, } from '../lib/client-factory.js'; +import { normalizeEnvVar } from '../lib/config.js'; import { emitDeprecationNotice } from '../lib/deprecate.js'; import { CLIError } from '../lib/errors.js'; import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js'; @@ -39,7 +40,7 @@ const DEFAULT_API_URL = 'https://api.testsprite.com'; */ function resolveReportedEndpoint(opts: InitOptions, deps: InitDeps): string { const env = deps.env ?? process.env; - const envApiUrl = env.TESTSPRITE_API_URL?.trim() || undefined; + const envApiUrl = normalizeEnvVar(env.TESTSPRITE_API_URL); let existing: string | undefined; try { existing = readProfile(opts.profile, { path: deps.credentialsPath })?.apiUrl; diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 463ddcc..cf56eaf 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -81,6 +81,28 @@ describe('loadConfig', () => { const config = loadConfig({ profile: 'dev', env: {}, credentialsPath }); expect(config.apiKey).toBe('sk-dev'); }); + + it('treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)', () => { + writeProfile( + 'default', + { apiKey: 'sk-file', apiUrl: 'https://api.example.com:8443' }, + { path: credentialsPath }, + ); + const config = loadConfig({ + env: { TESTSPRITE_API_URL: ' ' }, + credentialsPath, + }); + expect(config.apiUrl).toBe('https://api.example.com:8443'); + }); + + it('treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)', () => { + writeProfile('default', { apiKey: 'sk-file' }, { path: credentialsPath }); + const config = loadConfig({ + env: { TESTSPRITE_API_KEY: '' }, + credentialsPath, + }); + expect(config.apiKey).toBe('sk-file'); + }); }); describe('defaultConfigPath', () => { diff --git a/src/lib/config.ts b/src/lib/config.ts index 363c394..7f8065f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -17,6 +17,11 @@ export interface LoadConfigOptions { const DEFAULT_API_URL = 'https://api.testsprite.com'; +/** Treat empty / whitespace-only env values as unset for `??` resolution chains. */ +export function normalizeEnvVar(value: string | undefined): string | undefined { + return value?.trim() || undefined; +} + export function defaultConfigPath(): string { return join(homedir(), '.testsprite', 'config'); } @@ -38,9 +43,15 @@ export function loadConfig(options: LoadConfigOptions = {}): Config { const credentialsPath = options.credentialsPath ?? defaultCredentialsPath(); const fileEntry = readProfile(profile, { path: credentialsPath }); + // Empty / whitespace-only env vars are treated as unset so they do not + // short-circuit the `??` chain (e.g. `export TESTSPRITE_API_URL=` in a shell + // profile). Matches the normalization in auth configure and init/setup. + const envApiUrl = normalizeEnvVar(env.TESTSPRITE_API_URL); + const envApiKey = normalizeEnvVar(env.TESTSPRITE_API_KEY); + return { - apiUrl: options.endpointUrl ?? env.TESTSPRITE_API_URL ?? fileEntry?.apiUrl ?? DEFAULT_API_URL, - apiKey: env.TESTSPRITE_API_KEY ?? fileEntry?.apiKey, + apiUrl: options.endpointUrl ?? envApiUrl ?? fileEntry?.apiUrl ?? DEFAULT_API_URL, + apiKey: envApiKey ?? fileEntry?.apiKey, profile, }; } From 9feeacb9173106c52609b42eb0d7f0a4859a9a0f Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 Date: Tue, 7 Jul 2026 00:31:41 +0530 Subject: [PATCH 2/2] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20dry-?= =?UTF-8?q?run=20whoami=20uses=20offline=20client;=20fix=20whitespace=20en?= =?UTF-8?q?v=20key=20expectation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/auth.test.ts | 1 - src/lib/client-factory.test.ts | 21 ++++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/commands/auth.test.ts b/src/commands/auth.test.ts index c7d669c..c0b4e00 100644 --- a/src/commands/auth.test.ts +++ b/src/commands/auth.test.ts @@ -858,7 +858,6 @@ describe('runWhoami', () => { ...deps, env: { TESTSPRITE_API_URL: ' ' }, credentialsPath, - fetchImpl: makeFetch(meResponse()), }, ); const out = capture.stdout.join('\n'); diff --git a/src/lib/client-factory.test.ts b/src/lib/client-factory.test.ts index ab5fd99..d5135cc 100644 --- a/src/lib/client-factory.test.ts +++ b/src/lib/client-factory.test.ts @@ -339,7 +339,6 @@ describe('makeHttpClient - API key validation', () => { ['smart dash', 'sk-user-abc\u2013def'], ['smart quote', 'sk-user-\u201cabc\u201d'], ['emoji', 'sk-user-abc\u{1f600}'], - ['whitespace-only', ' '], ])('rejects a malformed configured API key with %s before fetch/retry', (_label, apiKey) => { const fetchImpl = vi.fn(); let caught: unknown; @@ -362,6 +361,26 @@ describe('makeHttpClient - API key validation', () => { expect(apiErr.exitCode).toBe(5); expect(apiErr.nextAction).toContain('api-key'); }); + + it('treats a whitespace-only TESTSPRITE_API_KEY env var as unset (AUTH_REQUIRED)', () => { + const fetchImpl = vi.fn(); + let caught: unknown; + try { + makeHttpClient( + { profile: 'default', output: 'json', debug: false, dryRun: false }, + { + env: { TESTSPRITE_API_KEY: ' ' } as NodeJS.ProcessEnv, + credentialsPath: NO_CREDS_PATH, + fetchImpl, + }, + ); + } catch (err) { + caught = err; + } + expect(fetchImpl).not.toHaveBeenCalled(); + expect(caught).toBeInstanceOf(ApiError); + expect((caught as ApiError).code).toBe('AUTH_REQUIRED'); + }); }); describe('assertValidApiKeyHeaderValue', () => {