fix(js-sandbox): resolve environment variable fallback behavior (#5439)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Chhavi Goyal 2025-10-28 13:38:55 -04:00 committed by GitHub
parent 453b5fc088
commit 9a4e5a7f7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1005 additions and 34 deletions

View file

@ -0,0 +1,864 @@
/**
* Environment Variable Fallback Behavior Tests
*
* Tests the fallback from currentValue to initialValue when currentValue is empty
* across pm.environment and hopp.env namespaces.
*/
import { describe, expect, test } from "vitest"
import { runTest } from "~/utils/test-helpers"
// All namespaces share the same environment variable fallback implementation
// Testing: pm.environment (active scope) and hopp.env
// Note: pm.globals uses same implementation as pm.environment, just different scope
const NAMESPACES = [
{
name: "pm.environment",
get: "pm.environment.get",
set: "pm.environment.set",
},
{ name: "hopp.env", get: "hopp.env.get", set: "hopp.env.set" },
] as const
describe("Environment Variable Fallback Behavior - All Namespaces", () => {
describe.each(NAMESPACES)(
"$name - currentValue empty string fallback",
({ get }) => {
test("should fallback to initialValue when currentValue is empty string", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: "", // Empty string
initialValue: "fallback_value",
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get fallback value", () => {
pm.expect(value).to.equal("fallback_value")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should return empty string when both currentValue and initialValue are empty", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: "",
initialValue: "",
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get empty string", () => {
pm.expect(value).to.equal("")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
}
)
describe.each(NAMESPACES)(
"$name - currentValue undefined/null fallback",
({ get }) => {
// Note: These tests check GET behavior with null/undefined in initial state.
// Setting null via pm.environment.set(key, null) is currently NOT supported.
// These tests validate fallback logic when null/undefined exists in the env state,
// which can occur through other means (e.g., test setup, external sources).
test("should fallback to initialValue when currentValue is undefined", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: undefined,
initialValue: "fallback_value",
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get fallback value", () => {
pm.expect(value).to.equal("fallback_value")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should fallback to initialValue when currentValue is null", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: null,
initialValue: "fallback_value",
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get fallback value for null", () => {
pm.expect(value).to.equal("fallback_value")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
}
)
describe.each(NAMESPACES)(
"$name - falsy values should NOT fallback",
({ get }) => {
test("should use currentValue when it is 0 (not fallback)", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: 0,
initialValue: 100,
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get 0, not fallback to 100", () => {
pm.expect(value).to.equal(0)
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should use currentValue when it is false (not fallback)", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: false,
initialValue: true,
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get false, not fallback to true", () => {
pm.expect(value).to.equal(false)
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should use currentValue when it is empty array (not fallback)", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: [],
initialValue: [1, 2, 3],
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get empty array, not fallback", () => {
pm.expect(value).to.be.an("array")
pm.expect(value.length).to.equal(0)
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should use currentValue when it is empty object (not fallback)", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: {},
initialValue: { key: "value" },
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("testVar")
pm.test("should get empty object, not fallback", () => {
pm.expect(value).to.be.an("object")
pm.expect(Object.keys(value).length).to.equal(0)
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
}
)
// NaN test - only for pm namespace which preserves non-string types
describe("pm.environment - NaN handling", () => {
test("should use currentValue when it is NaN (not fallback)", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: NaN,
initialValue: 100,
secret: false,
},
],
}
const result = await runTest(
`
const value = pm.environment.get("testVar")
pm.test("should get NaN, not fallback to 100", () => {
pm.expect(Number.isNaN(value)).to.be.true
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
})
describe.each(NAMESPACES)(
"$name - complex type preservation with fallback",
({ get }) => {
test("should fallback to initialValue array when currentValue is empty", async () => {
const envs = {
global: [],
selected: [
{
key: "users",
currentValue: "",
initialValue: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("users")
pm.test("should get array from initialValue", () => {
pm.expect(value).to.be.an("array")
pm.expect(value.length).to.equal(2)
pm.expect(value[0].name).to.equal("Alice")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should fallback to initialValue object when currentValue is null", async () => {
const envs = {
global: [],
selected: [
{
key: "config",
currentValue: null,
initialValue: { debug: true, maxRetries: 3 },
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("config")
pm.test("should get object from initialValue", () => {
pm.expect(value).to.be.an("object")
pm.expect(value.debug).to.equal(true)
pm.expect(value.maxRetries).to.equal(3)
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
}
)
describe.each(NAMESPACES)(
"$name - global vs selected scope fallback",
({ name, get }) => {
test("should fallback to initialValue in global scope", async () => {
const envs = {
global: [
{
key: "globalVar",
currentValue: "",
initialValue: "global_fallback",
secret: false,
},
],
selected: [],
}
// Use hopp.env.global.get for hopp, pm.globals.get for pm
const globalGet =
name === "pm.environment" ? "pm.globals.get" : "hopp.env.global.get"
const result = await runTest(
`
const value = ${globalGet}("globalVar")
pm.test("should get global fallback value", () => {
pm.expect(value).to.equal("global_fallback")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should fallback in selected scope even when global has a value", async () => {
const envs = {
global: [
{
key: "sharedVar",
currentValue: "global_value",
initialValue: "global_initial",
secret: false,
},
],
selected: [
{
key: "sharedVar",
currentValue: "", // Empty in selected
initialValue: "selected_initial",
secret: false,
},
],
}
const result = await runTest(
`
const value = ${get}("sharedVar")
pm.test("should get selected scope's initialValue, not global", () => {
pm.expect(value).to.equal("selected_initial")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
}
)
describe.each(NAMESPACES)(
"$name - runtime modification and fallback",
({ get, set }) => {
test("should use newly set value, not fallback to initialValue", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: "",
initialValue: "initial",
secret: false,
},
],
}
const result = await runTest(
`
// First, verify it falls back to initialValue
const before = ${get}("testVar")
pm.test("before set: should get initial value", () => {
pm.expect(before).to.equal("initial")
})
// Now set a new value
${set}("testVar", "runtime_value")
// Verify it uses the newly set value
const after = ${get}("testVar")
pm.test("after set: should get runtime value", () => {
pm.expect(after).to.equal("runtime_value")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should fallback when currentValue is set to empty string at runtime", async () => {
const envs = {
global: [],
selected: [
{
key: "testVar",
currentValue: "original",
initialValue: "fallback",
secret: false,
},
],
}
const result = await runTest(
`
// First, verify original value
const before = ${get}("testVar")
pm.test("before: should get original value", () => {
pm.expect(before).to.equal("original")
})
// Set to empty string
${set}("testVar", "")
// Should now fall back to initialValue
const after = ${get}("testVar")
pm.test("after setting empty: should fallback to initial", () => {
pm.expect(after).to.equal("fallback")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
}
)
// PM namespace returns undefined for non-existent keys
describe("pm.environment - non-existent variable behavior", () => {
test("should return undefined for non-existent variable (not fallback)", async () => {
const envs = {
global: [],
selected: [],
}
const result = await runTest(
`
const value = pm.environment.get("nonExistent")
pm.test("should get undefined for non-existent", () => {
pm.expect(value).to.be.undefined
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
})
// Hopp namespace returns null for non-existent keys
describe("hopp.env - non-existent variable behavior", () => {
test("should return null for non-existent variable (not fallback)", async () => {
const envs = {
global: [],
selected: [],
}
const result = await runTest(
`
const value = hopp.env.get("nonExistent")
pm.test("should get null for non-existent", () => {
pm.expect(value).to.be.null
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
})
})
// Additional tests for pm.variables which searches both scopes
describe("pm.variables - Fallback Behavior Across Scopes", () => {
test("should fallback to initialValue in selected scope first", async () => {
const envs = {
global: [
{
key: "sharedVar",
currentValue: "global_value",
initialValue: "global_initial",
secret: false,
},
],
selected: [
{
key: "sharedVar",
currentValue: "",
initialValue: "selected_initial",
secret: false,
},
],
}
const result = await runTest(
`
const value = pm.variables.get("sharedVar")
pm.test("should get selected scope's initialValue", () => {
pm.expect(value).to.equal("selected_initial")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should search global scope when selected scope variable doesn't exist", async () => {
const envs = {
global: [
{
key: "globalOnly",
currentValue: "",
initialValue: "global_fallback",
secret: false,
},
],
selected: [],
}
const result = await runTest(
`
const value = pm.variables.get("globalOnly")
pm.test("should get global scope's initialValue", () => {
pm.expect(value).to.equal("global_fallback")
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("should return undefined when variable doesn't exist in either scope", async () => {
const envs = {
global: [],
selected: [],
}
const result = await runTest(
`
const value = pm.variables.get("nonExistent")
pm.test("should get undefined", () => {
pm.expect(value).to.be.undefined
})
`,
envs
)()
expect(result).toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
})

View file

@ -13,6 +13,12 @@ import { createPmNamespaceMethods } from "../namespaces/pm-namespace"
import { createPwNamespaceMethods } from "../namespaces/pw-namespace"
type BaseInputsConfig = {
/**
* Environment variables typed as TestResult["envs"] for external API compatibility.
* At runtime, this will be mutated to contain SandboxValue types (arrays, objects, etc.)
* during script execution to support PM namespace compatibility.
* See `getSharedEnvMethods()` for detailed explanation of the type flow.
*/
envs: TestResult["envs"]
request: HoppRESTRequest
cookies: Cookie[] | null

View file

@ -22,11 +22,21 @@ type SandboxFunction = ReturnType<typeof defineSandboxFn>
* - Return values sent back to the sandbox
* - PM namespace compatibility (preserves non-string types like arrays, objects)
*
* Supported types for environment variable values:
* - Primitives: string, number, boolean, null, undefined
* - Objects: plain objects, arrays (recursively containing these types)
* - Unsupported: Functions, Symbols, and other non-serializable types
*
* Note: Typed as `any` because this type is used in multiple contexts:
* 1. Environment variable storage (supports primitives, objects, arrays)
* 2. Function parameters from user scripts (requires runtime validation)
* 3. Internal object properties (may include QuickJS handles)
*
* @example
* ```typescript
* // Function accepting values from user scripts
* const envSetAny = (key: SandboxValue, value: SandboxValue) => {
* // Runtime validation
* // Runtime validation required since type is `any`
* if (typeof key !== "string") throw new Error("Expected string key")
* // ... handle value
* }
@ -85,23 +95,67 @@ export type TestDescriptor = {
children: TestDescriptor[]
}
// Representation of a transformed state for environment variables in the sandbox
type TransformedEnvironmentVariable = {
/**
* Internal representation of environment variables within the sandbox runtime.
*
* Values can be complex types (arrays, objects) while executing scripts.
* This type is exported for internal use within js-sandbox but should NOT
* be used by consuming packages - use `EnvironmentVariable` instead.
*
* @internal
*/
export type SandboxEnvironmentVariable = {
key: string
currentValue: string
initialValue: string
currentValue: SandboxValue // Can be arrays, objects, primitives
initialValue: SandboxValue
secret: boolean
}
/**
* Defines the result of a test script execution
* Internal representation of the envs structure during sandbox execution.
* This is what's used internally, before serialization to TestResult["envs"].
*
* At runtime, environment variables are stored with SandboxValue types to support
* PM namespace compatibility (arrays, objects, etc.). The serialization to strings
* happens only when getUpdatedEnvs() is called at the end of script execution.
*
* Note: This type is structurally compatible with TestResult["envs"] at runtime,
* but TypeScript sees them as different types due to the SandboxValue vs string
* difference. Use type assertions when converting between them.
*
* @internal
*/
export type SandboxEnvs = {
global: SandboxEnvironmentVariable[]
selected: SandboxEnvironmentVariable[]
}
/**
* External representation of environment variables at the API boundary.
*
* All values are serialized to strings when crossing the sandbox boundary
* via getUpdatedEnvs() which calls JSON.stringify() on complex types.
*
* This is what consuming packages (hoppscotch-common, cli, etc.) receive.
*/
export type EnvironmentVariable = {
key: string
currentValue: string // Always string after serialization
initialValue: string // Always string after serialization
secret: boolean
}
/**
* Defines the result of a test script execution.
*
* Note: envs contain EnvironmentVariable (strings) not SandboxValue,
* because values are serialized when leaving the sandbox.
*/
export type TestResult = {
tests: TestDescriptor[]
envs: {
global: TransformedEnvironmentVariable[]
selected: TransformedEnvironmentVariable[]
global: EnvironmentVariable[]
selected: EnvironmentVariable[]
}
}
@ -280,7 +334,8 @@ export interface BaseInputs
cookieGetAll: SandboxFunction
cookieDelete: SandboxFunction
cookieClear: SandboxFunction
getUpdatedEnvs: () => SandboxValue
// Returns serialized env vars (SandboxValue -> string conversion happens here)
getUpdatedEnvs: () => TestResult["envs"]
getUpdatedCookies: () => Cookie[] | null
[key: string]: SandboxValue // Index signature for dynamic namespace properties
}

View file

@ -11,11 +11,11 @@ import { cloneDeep } from "lodash-es"
import {
Expectation,
GlobalEnvItem,
SelectedEnvItem,
TestDescriptor,
TestResult,
SandboxValue,
SandboxEnvironmentVariable,
SandboxEnvs,
} from "../types"
export type EnvSource = "active" | "global" | "all"
@ -26,45 +26,45 @@ export type EnvAPIOptions = {
const getEnv = (
envName: string,
envs: TestResult["envs"],
envs: SandboxEnvs,
options = { source: "all" }
) => {
if (options.source === "active") {
return O.fromNullable(
envs.selected.find((x: SelectedEnvItem) => x.key === envName)
envs.selected.find((x: SandboxEnvironmentVariable) => x.key === envName)
)
}
if (options.source === "global") {
return O.fromNullable(
envs.global.find((x: GlobalEnvItem) => x.key === envName)
envs.global.find((x: SandboxEnvironmentVariable) => x.key === envName)
)
}
return O.fromNullable(
envs.selected.find((x: SelectedEnvItem) => x.key === envName) ??
envs.global.find((x: GlobalEnvItem) => x.key === envName)
envs.selected.find((x: SandboxEnvironmentVariable) => x.key === envName) ??
envs.global.find((x: SandboxEnvironmentVariable) => x.key === envName)
)
}
const findEnvIndex = (
envName: string,
envList: SelectedEnvItem[] | GlobalEnvItem[]
envList: SandboxEnvironmentVariable[]
): number => {
return envList.findIndex(
(envItem: SelectedEnvItem) => envItem.key === envName
(envItem: SandboxEnvironmentVariable) => envItem.key === envName
)
}
const setEnv = (
envName: string,
envValue: SandboxValue,
envs: TestResult["envs"],
envs: SandboxEnvs,
options: { setInitialValue?: boolean; source: EnvSource } = {
setInitialValue: false,
source: "all",
}
): TestResult["envs"] => {
): SandboxEnvs => {
const { global, selected } = envs
const indexInSelected = findEnvIndex(envName, selected)
@ -108,9 +108,9 @@ const setEnv = (
const unsetEnv = (
envName: string,
envs: TestResult["envs"],
envs: SandboxEnvs,
options = { source: "all" }
): TestResult["envs"] => {
): SandboxEnvs => {
const { global, selected } = envs
const indexInSelected = findEnvIndex(envName, selected)
@ -156,7 +156,7 @@ export function getSharedEnvMethods(
}
}
pmSetAny: (key: string, value: SandboxValue, options?: EnvAPIOptions) => void
updatedEnvs: TestResult["envs"]
updatedEnvs: SandboxEnvs
}
/**
@ -179,14 +179,36 @@ export function getSharedEnvMethods(
resolve: (key: string) => string
}
}
updatedEnvs: TestResult["envs"]
updatedEnvs: SandboxEnvs
}
export function getSharedEnvMethods(
envs: TestResult["envs"],
isHoppNamespace = false
): unknown {
let updatedEnvs = envs
/**
* Type assertion explanation:
*
* The `envs` parameter is typed as `TestResult["envs"]` (with string values) for external API
* compatibility, but at runtime it contains `SandboxValue` types during script execution.
*
* Data flow:
* 1. Entry: External caller passes envs with string values
* { global: [{ key: "count", currentValue: "5", initialValue: "0" }], selected: [] }
*
* 2. Execution: Scripts mutate with complex types (PM namespace compatibility)
* pm.environment.set("users", [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }])
* pm.environment.set("config", { debug: true, maxRetries: 3 })
* // Now: currentValue is an array/object, not a string!
*
* 3. Exit: getUpdatedEnvs() serializes back to strings via JSON.stringify()
* { global: [{ key: "users", currentValue: "[{...}]", initialValue: "[]" }], ... }
*
* The cast acknowledges that during execution (steps 1-3), the runtime type is SandboxEnvs,
* even though the declared type is TestResult["envs"] for API boundary compatibility.
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
let updatedEnvs = envs as unknown as SandboxEnvs
const envGetFn = (
key: unknown,
@ -200,7 +222,19 @@ export function getSharedEnvMethods(
getEnv(key, updatedEnvs, options),
O.fold(
() => (options.fallbackToNull ? null : undefined),
(env) => env.currentValue // Return value as-is (PM namespace preserves types)
(env) => {
// Get the value to use (currentValue or fallback to initialValue)
// Treat undefined, empty string, and null as "empty" and fallback to initialValue
const valueToUse =
env.currentValue !== undefined &&
env.currentValue !== "" &&
env.currentValue !== null
? env.currentValue
: env.initialValue
// Preserve complex types (arrays, objects) for PM namespace compatibility
return valueToUse
}
)
)
@ -228,15 +262,27 @@ export function getSharedEnvMethods(
E.fromOption(() => "INVALID_KEY" as const),
E.map((e) => {
// Only resolve templates if the value is a string (PM namespace may have non-strings)
if (typeof e.currentValue === "string") {
return pipe(
parseTemplateStringE(e.currentValue, envVars),
E.getOrElse(() => e.currentValue)
)
// Get the value to use (currentValue or fallback to initialValue)
// Treat undefined, empty string, and null as "empty" and fallback to initialValue
const valueToUse =
e.currentValue !== undefined &&
e.currentValue !== "" &&
e.currentValue !== null
? e.currentValue
: e.initialValue
// Only resolve templates for string values
// Non-string values (arrays, objects, etc.) are returned as-is for PM namespace compatibility
if (typeof valueToUse !== "string") {
return valueToUse
}
// Return non-string values as-is (arrays, objects, null, etc.)
return e.currentValue
// For string values, resolve templates
return pipe(
parseTemplateStringE(valueToUse, envVars),
// If the recursive resolution failed, return the unresolved value
E.getOrElse(() => valueToUse)
)
}),
E.getOrElseW(() => (options.fallbackToNull ? null : undefined))