fix(js-sandbox): improve scripting value handling and serialization

- Fix null/undefined environment variable handling across namespaces
- Fix pm.request console.log output to display properly
- Add pm.request.id and pm.request.name type definitions
- Fix assertion error messages to show actual values
- Strip `export {};` from collection exports and legacy sandbox editor display
This commit is contained in:
jamesgeorge007 2025-10-29 17:58:20 +05:30
parent c0e3ff49b3
commit ae3d73bb32
8 changed files with 511 additions and 12 deletions

View file

@ -2831,9 +2831,12 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
if (collectionsType.value.type === "my-collections") {
const collectionJSON = JSON.stringify(collection, null, 2)
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
const cleanedCollectionJSON = collectionJSON.replace(/export \{\};\\n/g, "")
const name = (collection as HoppCollection).name
initializeDownloadCollection(collectionJSON, name)
initializeDownloadCollection(cleanedCollectionJSON, name)
} else {
if (!collection.id) return
exportLoading.value = true
@ -2850,8 +2853,14 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
const hoppColl = teamCollToHoppRESTColl(coll)
const collectionJSONString = JSON.stringify(hoppColl, null, 2)
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
const cleanedCollectionJSON = collectionJSONString.replace(
/export \{\};\\n/g,
""
)
await initializeDownloadCollection(
collectionJSONString,
cleanedCollectionJSON,
hoppColl.name
)
exportLoading.value = false

View file

@ -268,6 +268,19 @@ const getEditorLanguage = (
completer: Completer | undefined
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
const MODULE_PREFIX = "export {};\n" as const
/**
* Strips the `export {};\n` prefix from the value for display in the editor.
* The above is only used internally for Monaco editor's module scope,
* and should not be visible in the CodeMirror editor.
*/
const stripModulePrefix = (value?: string): string | undefined => {
return value?.startsWith(MODULE_PREFIX)
? value.slice(MODULE_PREFIX.length)
: value
}
export function useCodemirror(
el: Ref<any | null>,
value: Ref<string | undefined>,
@ -474,7 +487,10 @@ export function useCodemirror(
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: parseDoc(value.value, options.extendedEditorConfig.mode ?? ""),
doc: parseDoc(
stripModulePrefix(value.value),
options.extendedEditorConfig.mode ?? ""
),
extensions,
}),
// scroll to top when mounting
@ -514,13 +530,17 @@ export function useCodemirror(
if (!view.value && el.value) {
initView(el.value)
}
// Strip `export {};\n` before displaying in CodeMirror
const displayValue = stripModulePrefix(newVal) ?? ""
if (cachedValue.value !== newVal) {
view.value?.dispatch({
filter: false,
changes: {
from: 0,
to: view.value.state.doc.length,
insert: newVal,
insert: displayValue,
},
})
}

View file

@ -652,6 +652,8 @@ declare namespace pm {
}>
const request: Readonly<{
readonly id: string
readonly name: string
readonly url: Readonly<{
toString(): string
readonly protocol: string

View file

@ -106,9 +106,8 @@ describe("Environment Variable Fallback Behavior - All 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).
// Setting null via pm.environment.set(key, null) IS supported (via NULL_MARKER).
// These tests validate fallback logic when null/undefined exists in the env state.
test("should fallback to initialValue when currentValue is undefined", async () => {
const envs = {

View file

@ -0,0 +1,378 @@
/**
* Null and Undefined Value Preservation Across Namespaces
*
* Tests that null and undefined values are correctly preserved when setting
* and getting environment variables across pm, pw, and hopp namespaces.
*/
import { describe, expect, test } from "vitest"
import { runTest } from "~/utils/test-helpers"
describe("Null and undefined value preservation across namespaces", () => {
describe("Cross-namespace null value handling", () => {
test("pm.environment.set with null should work across pm, pw, and hopp namespaces", () => {
return expect(
runTest(
`
pm.environment.set('key', null)
pm.test("pm.environment.get returns actual null", () => {
pm.expect(pm.environment.get('key')).to.equal(null)
})
pm.test("typeof null should be 'object'", () => {
pm.expect(typeof pm.environment.get('key')).to.equal('object')
})
pm.test("pw.env.get returns actual null (cross-namespace)", () => {
pm.expect(pw.env.get('key')).to.equal(null)
})
pm.test("typeof via pw.env.get should be 'object'", () => {
pm.expect(typeof pw.env.get('key')).to.equal('object')
})
pm.test("hopp.env.get returns actual null (cross-namespace)", () => {
pm.expect(hopp.env.get('key')).to.equal(null)
})
pm.test("typeof via hopp.env.get should be 'object'", () => {
pm.expect(typeof hopp.env.get('key')).to.equal('object')
})
`,
{ global: [], selected: [] }
)()
).resolves.toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
descriptor: "pm.environment.get returns actual null",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof null should be 'object'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "pw.env.get returns actual null (cross-namespace)",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof via pw.env.get should be 'object'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor:
"hopp.env.get returns actual null (cross-namespace)",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof via hopp.env.get should be 'object'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("pm.environment.set with undefined should work across pm, pw, and hopp namespaces", () => {
return expect(
runTest(
`
pm.environment.set('undefKey', undefined)
pm.test("pm.environment.get returns actual undefined", () => {
pm.expect(pm.environment.get('undefKey')).to.equal(undefined)
})
pm.test("typeof undefined should be 'undefined'", () => {
pm.expect(typeof pm.environment.get('undefKey')).to.equal('undefined')
})
pm.test("pw.env.get returns actual undefined (cross-namespace)", () => {
pm.expect(pw.env.get('undefKey')).to.equal(undefined)
})
pm.test("typeof via pw.env.get should be 'undefined'", () => {
pm.expect(typeof pw.env.get('undefKey')).to.equal('undefined')
})
pm.test("hopp.env.get returns actual undefined (cross-namespace)", () => {
pm.expect(hopp.env.get('undefKey')).to.equal(undefined)
})
pm.test("typeof via hopp.env.get should be 'undefined'", () => {
pm.expect(typeof hopp.env.get('undefKey')).to.equal('undefined')
})
`,
{ global: [], selected: [] }
)()
).resolves.toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
descriptor: "pm.environment.get returns actual undefined",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof undefined should be 'undefined'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor:
"pw.env.get returns actual undefined (cross-namespace)",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof via pw.env.get should be 'undefined'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor:
"hopp.env.get returns actual undefined (cross-namespace)",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof via hopp.env.get should be 'undefined'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
})
describe("Assertion failure messages display actual values", () => {
test("null assertion failures should show actual 'null' value in error messages", () => {
return expect(
runTest(
`
pm.environment.set('nullKey', null)
pm.test("pm.environment.get error message should not contain marker", () => {
pm.expect(pm.environment.get('nullKey')).to.equal("this is not null")
})
pm.test("pw.env.get error message should not contain marker", () => {
pm.expect(pw.env.get('nullKey')).to.equal("this is not null")
})
hopp.test("hopp.env.get error message should not contain marker", () => {
hopp.expect(hopp.env.get('nullKey')).to.equal("this is not null")
})
`,
{ global: [], selected: [] }
)()
).resolves.toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
descriptor:
"pm.environment.get error message should not contain marker",
expectResults: expect.arrayContaining([
expect.objectContaining({
status: "fail",
message: expect.not.stringContaining("__HOPPSCOTCH_NULL__"),
}),
]),
}),
expect.objectContaining({
descriptor:
"pw.env.get error message should not contain marker",
expectResults: expect.arrayContaining([
expect.objectContaining({
status: "fail",
message: expect.not.stringContaining("__HOPPSCOTCH_NULL__"),
}),
]),
}),
expect.objectContaining({
descriptor:
"hopp.env.get error message should not contain marker",
expectResults: expect.arrayContaining([
expect.objectContaining({
status: "fail",
message: expect.not.stringContaining("__HOPPSCOTCH_NULL__"),
}),
]),
}),
]),
}),
])
)
})
test("undefined assertion failures should show actual 'undefined' value in error messages", () => {
return expect(
runTest(
`
pm.environment.set('undefKey', undefined)
pm.test("pm.environment.get error message should not contain marker", () => {
pm.expect(pm.environment.get('undefKey')).to.equal("this is not undefined")
})
pm.test("pw.env.get error message should not contain marker", () => {
pm.expect(pw.env.get('undefKey')).to.equal("this is not undefined")
})
hopp.test("hopp.env.get error message should not contain marker", () => {
hopp.expect(hopp.env.get('undefKey')).to.equal("this is not undefined")
})
`,
{ global: [], selected: [] }
)()
).resolves.toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
descriptor:
"pm.environment.get error message should not contain marker",
expectResults: expect.arrayContaining([
expect.objectContaining({
status: "fail",
message: expect.not.stringContaining(
"__HOPPSCOTCH_UNDEFINED__"
),
}),
]),
}),
expect.objectContaining({
descriptor:
"pw.env.get error message should not contain marker",
expectResults: expect.arrayContaining([
expect.objectContaining({
status: "fail",
message: expect.not.stringContaining(
"__HOPPSCOTCH_UNDEFINED__"
),
}),
]),
}),
expect.objectContaining({
descriptor:
"hopp.env.get error message should not contain marker",
expectResults: expect.arrayContaining([
expect.objectContaining({
status: "fail",
message: expect.not.stringContaining(
"__HOPPSCOTCH_UNDEFINED__"
),
}),
]),
}),
]),
}),
])
)
})
})
describe("pm.globals namespace null and undefined handling", () => {
test("pm.globals.set with null should work correctly", () => {
return expect(
runTest(
`
pm.globals.set('globalNull', null)
pm.test("pm.globals.get returns actual null", () => {
pm.expect(pm.globals.get('globalNull')).to.equal(null)
})
pm.test("typeof null should be 'object'", () => {
pm.expect(typeof pm.globals.get('globalNull')).to.equal('object')
})
`,
{ global: [], selected: [] }
)()
).resolves.toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
descriptor: "pm.globals.get returns actual null",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof null should be 'object'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
test("pm.globals.set with undefined should work correctly", () => {
return expect(
runTest(
`
pm.globals.set('globalUndef', undefined)
pm.test("pm.globals.get returns actual undefined", () => {
pm.expect(pm.globals.get('globalUndef')).to.equal(undefined)
})
pm.test("typeof undefined should be 'undefined'", () => {
pm.expect(typeof pm.globals.get('globalUndef')).to.equal('undefined')
})
`,
{ global: [], selected: [] }
)()
).resolves.toEqualRight(
expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
descriptor: "pm.globals.get returns actual undefined",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
expect.objectContaining({
descriptor: "typeof undefined should be 'undefined'",
expectResults: expect.arrayContaining([
expect.objectContaining({ status: "pass" }),
]),
}),
]),
}),
])
)
})
})
})

View file

@ -2992,6 +2992,37 @@
get auth() {
return globalThis.hopp.request.auth
},
// Custom serialization for console.log to match pre-request behavior
// This method is called by faraday-cage's marshalling system
toJSON() {
// Return a plain object with all properties expanded
// This ensures console.log(pm.request) shows the full structure
const urlParsed = this.url._parseUrl()
return {
id: this.id,
name: this.name,
url: {
protocol: urlParsed.protocol,
host: urlParsed.host,
hostname: urlParsed.host.join("."),
port: urlParsed.port,
path: urlParsed.path,
hash: urlParsed.hash || "",
query: this.url.query.all(),
},
method: this.method,
headers: this.headers.toObject(),
body: this.body,
auth: this.auth,
}
},
toString() {
return `Request { id: ${this.id}, name: ${this.name}, method: ${this.method}, url: ${this.url.toString()} }`
},
[Symbol.toStringTag]: "Request",
},
response: {

View file

@ -1163,6 +1163,37 @@
throw new Error("Auth must be an object or null")
}
},
// Custom serialization for console.log to ensure consistent behavior
// This method is called by faraday-cage's marshalling system
toJSON() {
// Return a plain object with all properties expanded
// This ensures console.log(pm.request) shows the full structure consistently
const urlParsed = this.url._parseUrl()
return {
id: this.id,
name: this.name,
url: {
protocol: urlParsed.protocol,
host: urlParsed.host,
hostname: urlParsed.host.join("."),
port: urlParsed.port,
path: urlParsed.path,
hash: urlParsed.hash || "",
query: this.url.query.all(),
},
method: this.method,
headers: this.headers.toObject(),
body: this.body,
auth: this.auth,
}
},
toString() {
return `Request { id: ${this.id}, name: ${this.name}, method: ${this.method}, url: ${this.url.toString()} }`
},
[Symbol.toStringTag]: "Request",
},
// Script context information

View file

@ -17,6 +17,7 @@ import {
SandboxEnvironmentVariable,
SandboxEnvs,
} from "../types"
import { UNDEFINED_MARKER, NULL_MARKER } from "~/constants/sandbox-markers"
export type EnvSource = "active" | "global" | "all"
export type EnvAPIOptions = {
@ -204,11 +205,10 @@ export function getSharedEnvMethods(
* 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.
* The `satisfies` check 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
let updatedEnvs = envs satisfies SandboxEnvs
const envGetFn = (
key: unknown,
@ -232,6 +232,15 @@ export function getSharedEnvMethods(
? env.currentValue
: env.initialValue
// Convert markers back to their actual types for script execution
// This ensures null/undefined values are properly represented in scripts
if (valueToUse === UNDEFINED_MARKER) {
return undefined
}
if (valueToUse === NULL_MARKER) {
return null
}
// Preserve complex types (arrays, objects) for PM namespace compatibility
return valueToUse
}
@ -271,6 +280,14 @@ export function getSharedEnvMethods(
? e.currentValue
: e.initialValue
// Convert markers back to their actual types
if (valueToUse === UNDEFINED_MARKER) {
return undefined
}
if (valueToUse === NULL_MARKER) {
return null
}
// Only resolve templates for string values
// Non-string values (arrays, objects, etc.) are returned as-is for PM namespace compatibility
if (typeof valueToUse !== "string") {
@ -399,7 +416,19 @@ export function getSharedEnvMethods(
getEnv(key, updatedEnvs, options),
O.fold(
() => undefined,
(env) => env.initialValue // Return as-is (PM namespace preserves types)
(env) => {
const initialValue = env.initialValue
// Convert markers back to their actual types
if (initialValue === UNDEFINED_MARKER) {
return undefined
}
if (initialValue === NULL_MARKER) {
return null
}
return initialValue // Return as-is (PM namespace preserves types)
}
)
)