diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 64b94d52..93080b36 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -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 diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index df8dbd95..283767ce 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -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, value: Ref, @@ -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, }, }) } diff --git a/packages/hoppscotch-common/src/types/post-request.d.ts b/packages/hoppscotch-common/src/types/post-request.d.ts index e733c86f..becf3df8 100644 --- a/packages/hoppscotch-common/src/types/post-request.d.ts +++ b/packages/hoppscotch-common/src/types/post-request.d.ts @@ -652,6 +652,8 @@ declare namespace pm { }> const request: Readonly<{ + readonly id: string + readonly name: string readonly url: Readonly<{ toString(): string readonly protocol: string 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 index 2d84689d..bf2ddc78 100644 --- 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 @@ -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 = { diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/null-undefined-value-preservation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/null-undefined-value-preservation.spec.ts new file mode 100644 index 00000000..418adac6 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/null-undefined-value-preservation.spec.ts @@ -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" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js index 78624ddd..809047e0 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js @@ -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: { diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js index f29164d7..15f80311 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js @@ -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 diff --git a/packages/hoppscotch-js-sandbox/src/utils/shared.ts b/packages/hoppscotch-js-sandbox/src/utils/shared.ts index e57394d9..3bdeac1e 100644 --- a/packages/hoppscotch-js-sandbox/src/utils/shared.ts +++ b/packages/hoppscotch-js-sandbox/src/utils/shared.ts @@ -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) + } ) )