Skip to content

Commit abc11cf

Browse files
committed
feat(env): add isInEnv helper and update getCI to check key existence
Add isInEnv() function to check if an environment variable key exists, regardless of its value. This is distinct from checking if a value is truthy - it returns true even for empty strings, "false", "0", etc. Update getCI() to use isInEnv(), so it returns true whenever the CI key exists in the environment, matching standard CI detection behavior where the presence of the CI key (not its value) indicates a CI environment. The helper follows the same override resolution order as getEnvValue(): 1. Isolated overrides (withEnv/withEnvSync) 2. Shared overrides (setEnv in beforeEach) 3. process.env (including vi.stubEnv)
1 parent 0955c7f commit abc11cf

File tree

4 files changed

+142
-30
lines changed

4 files changed

+142
-30
lines changed

src/env/ci.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
* Determines if code is running in a Continuous Integration environment.
44
*/
55

6-
import { envAsBoolean } from './helpers'
7-
import { getEnvValue } from './rewire'
6+
import { isInEnv } from './rewire'
87

98
/*@__NO_SIDE_EFFECTS__*/
109
export function getCI(): boolean {
11-
return envAsBoolean(getEnvValue('CI'))
10+
return isInEnv('CI')
1211
}

src/env/rewire.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
* - Thread-safe for concurrent test execution
1010
*/
1111

12+
import { hasOwn } from '../objects'
13+
14+
import { envAsBoolean } from './helpers'
15+
1216
let _async_hooks: typeof import('node:async_hooks') | undefined
1317
/**
1418
* Lazily load the async_hooks module to avoid Webpack errors.
@@ -24,8 +28,6 @@ function getAsyncHooks() {
2428
return _async_hooks as typeof import('node:async_hooks')
2529
}
2630

27-
import { envAsBoolean } from './helpers'
28-
2931
type EnvOverrides = Map<string, string | undefined>
3032

3133
// Isolated execution context storage for nested overrides (withEnv/withEnvSync)
@@ -75,6 +77,32 @@ export function getEnvValue(key: string): string | undefined {
7577
return process.env[key]
7678
}
7779

80+
/**
81+
* Check if an environment variable exists (has a key), checking overrides first.
82+
*
83+
* Resolution order:
84+
* 1. Isolated overrides (temporary - set via withEnv/withEnvSync)
85+
* 2. Shared overrides (persistent - set via setEnv in beforeEach)
86+
* 3. process.env (including vi.stubEnv modifications)
87+
*
88+
* @internal Used by env getters to check for key presence (not value truthiness)
89+
*/
90+
export function isInEnv(key: string): boolean {
91+
// Check isolated overrides first (highest priority - temporary via withEnv)
92+
const isolatedOverrides = isolatedOverridesStorage.getStore()
93+
if (isolatedOverrides?.has(key)) {
94+
return true
95+
}
96+
97+
// Check shared overrides (persistent via setEnv in beforeEach)
98+
if (sharedOverrides?.has(key)) {
99+
return true
100+
}
101+
102+
// Fall back to process.env (works with vi.stubEnv)
103+
return hasOwn(process.env, key)
104+
}
105+
78106
/**
79107
* Set an environment variable override for testing.
80108
* This does not modify process.env, only affects env getters.

test/unit/env/ci.test.mts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
* @fileoverview Unit tests for CI environment variable getter.
33
*
44
* Tests getCI() which detects CI/CD environments via the CI environment variable.
5-
* Validates truthy value parsing: "true", "TRUE", "1", "yes" all return true.
6-
* Returns false for falsy values or when CI is unset.
5+
* Returns true when the CI key exists in the environment, regardless of value.
6+
* Returns false only when CI is unset/undefined.
77
* Uses rewire for test isolation (setEnv/clearEnv/resetEnv) without polluting process.env.
88
* Critical for conditional behavior in CI environments (GitHub Actions, GitLab CI, etc.).
99
*/
1010

1111
import { getCI } from '@socketsecurity/lib/env/ci'
12-
import { resetEnv, setEnv } from '@socketsecurity/lib/env/rewire'
12+
import { clearEnv, resetEnv, setEnv } from '@socketsecurity/lib/env/rewire'
1313
import { afterEach, describe, expect, it } from 'vitest'
1414

1515
describe('env/ci', () => {
@@ -43,24 +43,24 @@ describe('env/ci', () => {
4343
expect(getCI()).toBe(true)
4444
})
4545

46-
it('should return false when CI is set to "false"', () => {
46+
it('should return true when CI is set to "false"', () => {
4747
setEnv('CI', 'false')
48-
expect(getCI()).toBe(false)
48+
expect(getCI()).toBe(true)
4949
})
5050

51-
it('should return false when CI is set to "0"', () => {
51+
it('should return true when CI is set to "0"', () => {
5252
setEnv('CI', '0')
53-
expect(getCI()).toBe(false)
53+
expect(getCI()).toBe(true)
5454
})
5555

56-
it('should return false when CI is set to "no"', () => {
56+
it('should return true when CI is set to "no"', () => {
5757
setEnv('CI', 'no')
58-
expect(getCI()).toBe(false)
58+
expect(getCI()).toBe(true)
5959
})
6060

61-
it('should return false when CI is empty string', () => {
61+
it('should return true when CI is empty string', () => {
6262
setEnv('CI', '')
63-
expect(getCI()).toBe(false)
63+
expect(getCI()).toBe(true)
6464
})
6565

6666
it('should handle mixed case true', () => {
@@ -73,24 +73,24 @@ describe('env/ci', () => {
7373
expect(getCI()).toBe(true)
7474
})
7575

76-
it('should handle arbitrary strings as false', () => {
76+
it('should handle arbitrary strings as true', () => {
7777
setEnv('CI', 'maybe')
78-
expect(getCI()).toBe(false)
78+
expect(getCI()).toBe(true)
7979
})
8080

81-
it('should handle updating CI value from false to true', () => {
81+
it('should handle updating CI value (all remain true when key exists)', () => {
8282
setEnv('CI', 'false')
83-
expect(getCI()).toBe(false)
83+
expect(getCI()).toBe(true)
8484

8585
setEnv('CI', 'true')
8686
expect(getCI()).toBe(true)
8787
})
8888

89-
it('should handle updating CI value from true to false', () => {
89+
it('should return false when CI is cleared', () => {
9090
setEnv('CI', 'true')
9191
expect(getCI()).toBe(true)
9292

93-
setEnv('CI', 'false')
93+
clearEnv('CI')
9494
expect(getCI()).toBe(false)
9595
})
9696

@@ -103,15 +103,15 @@ describe('env/ci', () => {
103103

104104
it('should handle numeric strings other than 1', () => {
105105
setEnv('CI', '2')
106-
expect(getCI()).toBe(false)
106+
expect(getCI()).toBe(true)
107107

108108
setEnv('CI', '100')
109-
expect(getCI()).toBe(false)
109+
expect(getCI()).toBe(true)
110110
})
111111

112112
it('should handle whitespace in values', () => {
113113
setEnv('CI', ' true ')
114-
expect(getCI()).toBe(false) // whitespace makes it not match
114+
expect(getCI()).toBe(true) // any value means CI exists
115115

116116
setEnv('CI', 'true')
117117
expect(getCI()).toBe(true)
@@ -129,7 +129,7 @@ describe('env/ci', () => {
129129

130130
it('should handle special characters', () => {
131131
setEnv('CI', 'true!')
132-
expect(getCI()).toBe(false)
132+
expect(getCI()).toBe(true)
133133
})
134134

135135
it('should handle GitHub Actions CI', () => {

test/unit/env/rewire.test.mts

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import { getSocketDebug } from '@socketsecurity/lib/env/socket'
1515
import {
1616
clearEnv,
1717
hasOverride,
18+
isInEnv,
1819
resetEnv,
1920
setEnv,
2021
} from '@socketsecurity/lib/env/rewire'
21-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
22+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2223

2324
describe('env rewiring', () => {
2425
// Clean up after each test to avoid state leakage
@@ -51,8 +52,12 @@ describe('env rewiring', () => {
5152
setEnv('CI', '1')
5253
expect(getCI()).toBe(true)
5354

54-
// Override CI to false
55+
// CI with empty string still returns true (key exists)
5556
setEnv('CI', '')
57+
expect(getCI()).toBe(true)
58+
59+
// CI returns false only when cleared
60+
clearEnv('CI')
5661
expect(getCI()).toBe(false)
5762
})
5863

@@ -91,9 +96,12 @@ describe('env rewiring', () => {
9196
expect(getCI()).toBe(true)
9297
})
9398

94-
it('test 2: should run with CI=false', () => {
99+
it('test 2: should run with CI cleared', () => {
95100
setEnv('CI', 'false')
96-
expect(getCI()).toBe(false)
101+
expect(getCI()).toBe(true) // Key exists, so true
102+
103+
clearEnv('CI')
104+
expect(getCI()).toBe(false) // Key doesn't exist
97105
})
98106

99107
it('test 3: should not be affected by previous tests', () => {
@@ -147,4 +155,81 @@ describe('env rewiring', () => {
147155
expect(hasOverride('CI')).toBe(false)
148156
})
149157
})
158+
159+
describe('isInEnv()', () => {
160+
it('should return true when key exists with truthy value', () => {
161+
setEnv('TEST_KEY', 'value')
162+
expect(isInEnv('TEST_KEY')).toBe(true)
163+
})
164+
165+
it('should return true when key exists with empty string', () => {
166+
setEnv('TEST_KEY', '')
167+
expect(isInEnv('TEST_KEY')).toBe(true)
168+
})
169+
170+
it('should return true when key exists with "false" string', () => {
171+
setEnv('TEST_KEY', 'false')
172+
expect(isInEnv('TEST_KEY')).toBe(true)
173+
})
174+
175+
it('should return true when key exists with "0" string', () => {
176+
setEnv('TEST_KEY', '0')
177+
expect(isInEnv('TEST_KEY')).toBe(true)
178+
})
179+
180+
it('should return false when key does not exist', () => {
181+
// Use a key that definitely doesn't exist
182+
expect(isInEnv('NONEXISTENT_KEY_12345')).toBe(false)
183+
})
184+
185+
it('should return false when key is cleared', () => {
186+
setEnv('TEST_KEY', 'value')
187+
expect(isInEnv('TEST_KEY')).toBe(true)
188+
189+
clearEnv('TEST_KEY')
190+
expect(isInEnv('TEST_KEY')).toBe(false)
191+
})
192+
193+
it('should handle undefined values correctly', () => {
194+
setEnv('TEST_KEY', undefined)
195+
// undefined means the key is set but has no value
196+
expect(isInEnv('TEST_KEY')).toBe(true)
197+
})
198+
199+
it('should check isolated overrides first', () => {
200+
// Set shared override
201+
setEnv('TEST_KEY', 'shared')
202+
expect(isInEnv('TEST_KEY')).toBe(true)
203+
204+
// Shared override should still work
205+
clearEnv('TEST_KEY')
206+
expect(isInEnv('TEST_KEY')).toBe(false)
207+
})
208+
209+
it('should work with real process.env values', () => {
210+
// PATH should exist in process.env
211+
expect(isInEnv('PATH')).toBe(true)
212+
})
213+
214+
it('should detect keys added via vi.stubEnv', () => {
215+
vi.stubEnv('VITEST_STUBBED_KEY', 'stubbed-value')
216+
expect(isInEnv('VITEST_STUBBED_KEY')).toBe(true)
217+
vi.unstubAllEnvs()
218+
})
219+
220+
it('should prioritize overrides over process.env', () => {
221+
// Set a value in process.env first
222+
vi.stubEnv('TEST_PRIORITY_KEY', 'process-env-value')
223+
expect(isInEnv('TEST_PRIORITY_KEY')).toBe(true)
224+
225+
// Override should still be checked
226+
setEnv('TEST_PRIORITY_KEY', undefined)
227+
expect(isInEnv('TEST_PRIORITY_KEY')).toBe(true)
228+
229+
clearEnv('TEST_PRIORITY_KEY')
230+
expect(isInEnv('TEST_PRIORITY_KEY')).toBe(true) // Falls back to process.env
231+
232+
vi.unstubAllEnvs()
233+
})
234+
})
150235
})

0 commit comments

Comments
 (0)