From 9a4e5a7f7e9b2714744e2d8d8954a872f4c3beec Mon Sep 17 00:00:00 2001 From: Chhavi Goyal Date: Tue, 28 Oct 2025 13:38:55 -0400 Subject: [PATCH] fix(js-sandbox): resolve environment variable fallback behavior (#5439) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: nivedin Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com> --- .../combined/env-fallback-behavior.spec.ts | 864 ++++++++++++++++++ .../src/cage-modules/utils/base-inputs.ts | 6 + .../hoppscotch-js-sandbox/src/types/index.ts | 73 +- .../hoppscotch-js-sandbox/src/utils/shared.ts | 96 +- 4 files changed, 1005 insertions(+), 34 deletions(-) create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts new file mode 100644 index 00000000..2d84689d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/env-fallback-behavior.spec.ts @@ -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" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts index b6238f3f..9dc09210 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts @@ -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 diff --git a/packages/hoppscotch-js-sandbox/src/types/index.ts b/packages/hoppscotch-js-sandbox/src/types/index.ts index f7979da7..467de548 100644 --- a/packages/hoppscotch-js-sandbox/src/types/index.ts +++ b/packages/hoppscotch-js-sandbox/src/types/index.ts @@ -22,11 +22,21 @@ type SandboxFunction = ReturnType * - 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 } diff --git a/packages/hoppscotch-js-sandbox/src/utils/shared.ts b/packages/hoppscotch-js-sandbox/src/utils/shared.ts index 7ed653f2..e57394d9 100644 --- a/packages/hoppscotch-js-sandbox/src/utils/shared.ts +++ b/packages/hoppscotch-js-sandbox/src/utils/shared.ts @@ -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))