From 5310b9fb407bc8c8837641aa52cec01f789e554f Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:39:04 +0530 Subject: [PATCH] fix(cli): strip module prefix before script execution (#5835) --- .../collections/scripting-revamp-coll.json | 8 +- packages/hoppscotch-cli/src/utils/mutators.ts | 16 ++++ .../hoppscotch-cli/src/utils/pre-request.ts | 92 ++++++++++--------- packages/hoppscotch-cli/src/utils/test.ts | 3 +- 4 files changed, 71 insertions(+), 48 deletions(-) diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json index a7d1b45c..f236cd7a 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json @@ -19,7 +19,7 @@ } ], "preRequestScript": "", - "testScript": "hopp.test(\"`hopp.response.body.asJSON()` parses response body as JSON\", () => {\n const parsedData = JSON.parse(hopp.response.body.asJSON().data)\n\n hopp.expect(parsedData.name).toBe('John Doe')\n hopp.expect(parsedData.age).toBeType(\"number\")\n})\n\npm.test(\"`pm.response.json()` parses response body as JSON\", () => {\n const parsedData = JSON.parse(pm.response.json().data)\n\n pm.expect(parsedData.name).toBe('John Doe')\n pm.expect(parsedData.age).toBeType(\"number\")\n})\n\nhopp.test(\"`hopp.response.body.asText()` parses response body as plain text\", () => {\n const textResponse = hopp.response.body.asText()\n hopp.expect(textResponse).toInclude('\\\"test-header\\\":\\\"test\\\"')\n})\n\npm.test(\"`pm.response.text()` parses response body as plain text\", () => {\n const textResponse = pm.response.text()\n pm.expect(textResponse).toInclude('\\\"test-header\\\":\\\"test\\\"')\n})\n\nhopp.test(\"hopp.response.bytes()` parses response body as raw bytes\", () => {\n const rawResponse = hopp.response.body.bytes()\n\n hopp.expect(rawResponse[0]).toBe(123)\n})\n\npm.test(\"pm.response.stream` parses response body as raw bytes\", () => {\n const rawResponse = pm.response.stream\n\n pm.expect(rawResponse[0]).toBe(123)\n})\n", + "testScript": "export {};\nhopp.test(\"`hopp.response.body.asJSON()` parses response body as JSON\", () => {\n const parsedData = JSON.parse(hopp.response.body.asJSON().data)\n\n hopp.expect(parsedData.name).toBe('John Doe')\n hopp.expect(parsedData.age).toBeType(\"number\")\n})\n\npm.test(\"`pm.response.json()` parses response body as JSON\", () => {\n const parsedData = JSON.parse(pm.response.json().data)\n\n pm.expect(parsedData.name).toBe('John Doe')\n pm.expect(parsedData.age).toBeType(\"number\")\n})\n\nhopp.test(\"`hopp.response.body.asText()` parses response body as plain text\", () => {\n const textResponse = hopp.response.body.asText()\n hopp.expect(textResponse).toInclude('\\\"test-header\\\":\\\"test\\\"')\n})\n\npm.test(\"`pm.response.text()` parses response body as plain text\", () => {\n const textResponse = pm.response.text()\n pm.expect(textResponse).toInclude('\\\"test-header\\\":\\\"test\\\"')\n})\n\nhopp.test(\"hopp.response.bytes()` parses response body as raw bytes\", () => {\n const rawResponse = hopp.response.body.bytes()\n\n hopp.expect(rawResponse[0]).toBe(123)\n})\n\npm.test(\"pm.response.stream` parses response body as raw bytes\", () => {\n const rawResponse = pm.response.stream\n\n pm.expect(rawResponse[0]).toBe(123)\n})\n", "auth": { "authType": "inherit", "authActive": true @@ -69,7 +69,7 @@ "endpoint": "https://echo.hoppscotch.io", "params": [], "headers": [], - "preRequestScript": "hopp.env.set('test_key', 'test_value')\nhopp.env.set('recursive_key', '<>')\nhopp.env.global.set('global_key', 'global_value')\nhopp.env.active.set('active_key', 'active_value')\n\n// `pm` namespace equivalents\npm.variables.set('pm_test_key', 'pm_test_value')\npm.environment.set('pm_active_key', 'pm_active_value')\npm.globals.set('pm_global_key', 'pm_global_value')\n", + "preRequestScript": "export {};\nhopp.env.set('test_key', 'test_value')\nhopp.env.set('recursive_key', '<>')\nhopp.env.global.set('global_key', 'global_value')\nhopp.env.active.set('active_key', 'active_value')\n\n// `pm` namespace equivalents\npm.variables.set('pm_test_key', 'pm_test_value')\npm.environment.set('pm_active_key', 'pm_active_value')\npm.globals.set('pm_global_key', 'pm_global_value')\n", "testScript": "\nhopp.test('`hopp.env.get()` retrieves environment variables', () => {\n const value = hopp.env.get('test_key')\n hopp.expect(value).toBe('test_value')\n})\n\npm.test('`pm.variables.get()` retrieves environment variables', () => {\n const value = pm.variables.get('test_key')\n pm.expect(value).toBe('test_value')\n})\n\nhopp.test('`hopp.env.getRaw()` retrieves raw environment variables without resolution', () => {\n const rawValue = hopp.env.getRaw('recursive_key')\n hopp.expect(rawValue).toBe('<>')\n})\n\nhopp.test('`hopp.env.get()` resolves recursive environment variables', () => {\n const resolvedValue = hopp.env.get('recursive_key')\n hopp.expect(resolvedValue).toBe('test_value')\n})\n\npm.test('`pm.variables.replaceIn()` resolves template variables', () => {\n const resolved = pm.variables.replaceIn('Value is {{test_key}}')\n pm.expect(resolved).toBe('Value is test_value')\n})\n\nhopp.test('`hopp.env.global.get()` retrieves global environment variables', () => {\n const globalValue = hopp.env.global.get('global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (globalValue) {\n hopp.expect(globalValue).toBe('global_value')\n }\n})\n\npm.test('`pm.globals.get()` retrieves global environment variables', () => {\n const globalValue = pm.globals.get('global_key')\n\n // `pm.globals` would be empty for the CLI\n if (globalValue) {\n pm.expect(globalValue).toBe('global_value')\n }\n})\n\nhopp.test('`hopp.env.active.get()` retrieves active environment variables', () => {\n const activeValue = hopp.env.active.get('active_key')\n hopp.expect(activeValue).toBe('active_value')\n})\n\npm.test('`pm.environment.get()` retrieves active environment variables', () => {\n const activeValue = pm.environment.get('active_key')\n pm.expect(activeValue).toBe('active_value')\n})\n\nhopp.test('Environment methods return null for non-existent keys', () => {\n hopp.expect(hopp.env.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.getRaw('non_existent')).toBe(null)\n hopp.expect(hopp.env.global.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.active.get('non_existent')).toBe(null)\n})\n\npm.test('`pm` environment methods handle non-existent keys correctly', () => {\n pm.expect(pm.variables.get('non_existent')).toBe(undefined)\n pm.expect(pm.environment.get('non_existent')).toBe(undefined)\n pm.expect(pm.globals.get('non_existent')).toBe(undefined)\n pm.expect(pm.variables.has('non_existent')).toBe(false)\n pm.expect(pm.environment.has('non_existent')).toBe(false)\n pm.expect(pm.globals.has('non_existent')).toBe(false)\n})\n\npm.test('`pm` variables set in pre-request script are accessible', () => {\n pm.expect(pm.variables.get('pm_test_key')).toBe('pm_test_value')\n pm.expect(pm.environment.get('pm_active_key')).toBe('pm_active_value')\n\n const pmGlobalValue = hopp.env.global.get('pm_global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (pmGlobalValue) {\n hopp.expect(pmGlobalValue).toBe('pm_global_value')\n }\n})\n", "auth": { "authType": "inherit", @@ -760,7 +760,7 @@ "endpoint": "https://echo.hoppscotch.io", "params": [], "headers": [], - "preRequestScript": "export {};", + "preRequestScript": "export {};\n", "testScript": "\n// Map & Set Assertions\npm.test('Map assertions - size property', () => {\n const map = new Map([['key1', 'value1'], ['key2', 'value2']])\n pm.expect(map).to.have.property('size', 2)\n pm.expect(map.size).to.equal(2)\n})\n\npm.test('Set assertions - size property', () => {\n const set = new Set([1, 2, 3, 4])\n pm.expect(set).to.have.property('size', 4)\n pm.expect(set.size).to.equal(4)\n})\n\npm.test('Map instanceOf assertion', () => {\n const map = new Map()\n pm.expect(map).to.be.instanceOf(Map)\n pm.expect(map).to.be.an.instanceOf(Map)\n})\n\npm.test('Set instanceOf assertion', () => {\n const set = new Set()\n pm.expect(set).to.be.instanceOf(Set)\n pm.expect(set).to.be.an.instanceOf(Set)\n})\n\n// Advanced Chai - closeTo\npm.test('closeTo - validates numbers within delta', () => {\n pm.expect(3.14159).to.be.closeTo(3.14, 0.01)\n pm.expect(10.5).to.be.closeTo(11, 1)\n})\n\npm.test('closeTo - negation works', () => {\n pm.expect(100).to.not.be.closeTo(50, 10)\n pm.expect(3.14).to.not.be.closeTo(10, 0.1)\n})\n\npm.test('approximately - alias for closeTo', () => {\n pm.expect(2.5).to.approximately(2.4, 0.2)\n pm.expect(99.99).to.approximately(100, 0.1)\n})\n\n// Advanced Chai - finite\npm.test('finite - validates finite numbers', () => {\n pm.expect(123).to.be.finite\n pm.expect(0).to.be.finite\n pm.expect(-456).to.be.finite\n})\n\npm.test('finite - negation for Infinity', () => {\n pm.expect(Infinity).to.not.be.finite\n pm.expect(-Infinity).to.not.be.finite\n pm.expect(NaN).to.not.be.finite\n})\n\n// Advanced Chai - satisfy\npm.test('satisfy - custom predicate function', () => {\n pm.expect(10).to.satisfy((num) => num > 5)\n pm.expect('hello').to.satisfy((str) => str.length === 5)\n})\n\npm.test('satisfy - complex validation', () => {\n const obj = { name: 'test', value: 100 }\n pm.expect(obj).to.satisfy((o) => o.value > 50 && o.name.length > 0)\n})\n\npm.test('satisfy - negation works', () => {\n pm.expect(5).to.not.satisfy((num) => num > 10)\n pm.expect('abc').to.not.satisfy((str) => str.length > 5)\n})\n\n// Advanced Chai - respondTo\npm.test('respondTo - validates method existence', () => {\n class TestClass {\n testMethod() { return 'test' }\n anotherMethod() { return 'another' }\n }\n pm.expect(TestClass).to.respondTo('testMethod')\n pm.expect(TestClass).to.respondTo('anotherMethod')\n})\n\npm.test('respondTo - with itself for static methods', () => {\n class MyClass {\n static staticMethod() { return 'static' }\n instanceMethod() { return 'instance' }\n }\n pm.expect(MyClass).itself.to.respondTo('staticMethod')\n pm.expect(MyClass).to.not.itself.respondTo('instanceMethod')\n pm.expect(MyClass).to.respondTo('instanceMethod')\n})\n\n// Property Ownership - own.property\npm.test('own.property - distinguishes own vs inherited', () => {\n const parent = { inherited: true }\n const obj = Object.create(parent)\n obj.own = true\n pm.expect(obj).to.have.own.property('own')\n pm.expect(obj).to.not.have.own.property('inherited')\n pm.expect(obj).to.have.property('inherited')\n})\n\npm.test('deep.own.property - deep check with ownership', () => {\n const proto = { shared: 'inherited' }\n const obj = Object.create(proto)\n obj.data = { nested: 'value' }\n pm.expect(obj).to.have.deep.own.property('data', { nested: 'value' })\n pm.expect(obj).to.not.have.deep.own.property('shared')\n})\n\npm.test('ownProperty - alias for own.property', () => {\n const obj = { prop: 'value' }\n pm.expect(obj).to.have.ownProperty('prop')\n pm.expect(obj).to.have.ownProperty('prop', 'value')\n})\n\n// Hopp namespace parity tests\npm.test('hopp.expect Map/Set support', () => {\n const map = new Map([['x', 1]])\n const set = new Set([1, 2])\n hopp.expect(map.size).toBe(1)\n hopp.expect(set.size).toBe(2)\n})\n\npm.test('hopp.expect closeTo support', () => {\n hopp.expect(3.14).to.be.closeTo(3.1, 0.1)\n hopp.expect(10).to.be.closeTo(10.5, 1)\n})\n\npm.test('hopp.expect finite support', () => {\n hopp.expect(42).to.be.finite\n hopp.expect(Infinity).to.not.be.finite\n})\n\npm.test('hopp.expect satisfy support', () => {\n hopp.expect(100).to.satisfy((n) => n > 50)\n hopp.expect('test').to.satisfy((s) => s.length === 4)\n})\n\npm.test('hopp.expect respondTo support', () => {\n class TestClass { method() {} }\n hopp.expect(TestClass).to.respondTo('method')\n})\n\npm.test('hopp.expect own.property support', () => {\n const obj = Object.create({ inherited: 1 })\n obj.own = 2\n hopp.expect(obj).to.have.own.property('own')\n hopp.expect(obj).to.not.have.own.property('inherited')\n})\n\npm.test('hopp.expect ordered.members support', () => {\n const arr = ['a', 'b', 'c']\n hopp.expect(arr).to.have.ordered.members(['a', 'b', 'c'])\n})\n", "auth": { "authType": "inherit", @@ -1683,4 +1683,4 @@ "headers": [], "variables": [], "description": null -} \ No newline at end of file +} diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts index 153e66b2..5348ba17 100644 --- a/packages/hoppscotch-cli/src/utils/mutators.ts +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -158,3 +158,19 @@ export async function parseCollectionData( return getValidRequests(collectionSchemaParsedResult.data, pathOrId); } + +/** + * Module prefix added by Monaco editor for TypeScript module mode. + */ +const MODULE_PREFIX = "export {};\n" as const; + +/** + * Strips `export {};\n` prefix from scripts before sandbox execution. + * The prefix is added by the web app's Monaco editor for IntelliSense + * and must be removed before execution. + */ +export const stripModulePrefix = (script: string): string => { + return script.startsWith(MODULE_PREFIX) + ? script.slice(MODULE_PREFIX.length) + : script; +}; diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 1b9d0151..dea46d10 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -1,17 +1,17 @@ import { Environment, EnvironmentVariable, + HoppCollectionVariable, HoppRESTRequest, + calculateHawkHeader, + generateJWTToken, parseBodyEnvVariablesE, parseRawKeyValueEntriesE, parseTemplateString, parseTemplateStringE, - generateJWTToken, - HoppCollectionVariable, - calculateHawkHeader, } from "@hoppscotch/data"; import { runPreRequestScript } from "@hoppscotch/js-sandbox/node"; -import { createHoppFetchHook } from "./hopp-fetch"; +import { AwsV4Signer } from "aws4fetch"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; import * as O from "fp-ts/Option"; @@ -20,22 +20,22 @@ import * as TE from "fp-ts/TaskEither"; import { flow, pipe } from "fp-ts/function"; import * as S from "fp-ts/string"; import qs from "qs"; -import { AwsV4Signer } from "aws4fetch"; +import { createHoppFetchHook } from "./hopp-fetch"; import { EffectiveHoppRESTRequest } from "../interfaces/request"; import { HoppCLIError, error } from "../types/errors"; import { HoppEnvs } from "../types/request"; import { PreRequestMetrics } from "../types/response"; -import { isHoppCLIError } from "./checks"; -import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; -import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; -import { toFormData } from "./mutators"; import { DigestAuthParams, fetchInitialDigestAuthInfo, generateDigestAuthHeader, } from "./auth/digest"; +import { isHoppCLIError } from "./checks"; +import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; +import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; import { stripComments } from "./jsonc"; +import { stripModulePrefix, toFormData } from "./mutators"; /** * Runs pre-request-script runner over given request which extracts set ENVs and @@ -60,7 +60,7 @@ export const preRequestScriptRunner = ( return pipe( TE.of(request), TE.chain(({ preRequestScript }) => - runPreRequestScript(preRequestScript, { + runPreRequestScript(stripModulePrefix(preRequestScript), { envs, experimentalScriptingSandbox, request, @@ -586,7 +586,10 @@ function getFinalBodyFromRequest( // and vendor-specific JSON media types (for example those with a +json suffix // or subtypes whose names end with "json" or "-json"). if (request.body.contentType) { - const mimeType = request.body.contentType.split(";")[0].trim().toLowerCase(); + const mimeType = request.body.contentType + .split(";")[0] + .trim() + .toLowerCase(); if ( mimeType === "application/json" || @@ -594,43 +597,46 @@ function getFinalBodyFromRequest( mimeType.endsWith("/json") || mimeType.endsWith("-json") ) { - const envResult = parseBodyEnvVariablesE(request.body.body, resolvedVariables); - - if (E.isLeft(envResult)) { - return E.left( - error({ - code: "PARSING_ERROR", - data: `${request.body.body} (${envResult.left})`, - }) + const envResult = parseBodyEnvVariablesE( + request.body.body, + resolvedVariables ); - } - const bodyString = envResult.right; + if (E.isLeft(envResult)) { + return E.left( + error({ + code: "PARSING_ERROR", + data: `${request.body.body} (${envResult.left})`, + }) + ); + } - // If the body string is empty or null, return null - if (!bodyString || S.isEmpty(bodyString.trim())) { - return E.right(null); - } + const bodyString = envResult.right; - // Strip comments and trailing commas from JSONC - // This ensures collections with comments work the same in CLI as in desktop app - const cleanedBody = stripComments(bodyString); + // If the body string is empty or null, return null + if (!bodyString || S.isEmpty(bodyString.trim())) { + return E.right(null); + } - // Try to parse the JSON body - try { - const parsedBody = JSON.parse(cleanedBody); - return E.right(JSON.stringify(parsedBody)); - } catch (err) { - // If parsing fails after stripping comments, return error to provide - // immediate feedback instead of sending invalid JSON to the API. - // Use original template string to avoid leaking secrets from env vars. - return E.left( - error({ - code: "PARSING_ERROR", - data: `${request.body.body} (Invalid JSON in request body: ${err instanceof Error ? err.message : String(err)})`, - }) - ); - } + // Strip comments and trailing commas from JSONC + // This ensures collections with comments work the same in CLI as in desktop app + const cleanedBody = stripComments(bodyString); + + // Try to parse the JSON body + try { + const parsedBody = JSON.parse(cleanedBody); + return E.right(JSON.stringify(parsedBody)); + } catch (err) { + // If parsing fails after stripping comments, return error to provide + // immediate feedback instead of sending invalid JSON to the API. + // Use original template string to avoid leaking secrets from env vars. + return E.left( + error({ + code: "PARSING_ERROR", + data: `${request.body.body} (Invalid JSON in request body: ${err instanceof Error ? err.message : String(err)})`, + }) + ); + } } } return pipe( diff --git a/packages/hoppscotch-cli/src/utils/test.ts b/packages/hoppscotch-cli/src/utils/test.ts index 0ada256d..f358bcf6 100644 --- a/packages/hoppscotch-cli/src/utils/test.ts +++ b/packages/hoppscotch-cli/src/utils/test.ts @@ -18,6 +18,7 @@ import { HoppEnvs } from "../types/request"; import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response"; import { getDurationInSeconds } from "./getters"; import { createHoppFetchHook } from "./hopp-fetch"; +import { stripModulePrefix } from "./mutators"; /** * Executes test script and runs testDescriptorParser to generate test-report using @@ -52,7 +53,7 @@ export const testRunner = ( const experimentalScriptingSandbox = !legacySandbox; const hoppFetchHook = createHoppFetchHook(); - return runTestScript(request.testScript, { + return runTestScript(stripModulePrefix(request.testScript), { envs, request, response: effectiveResponse,