From aab2924139998b3868f1b190b4b62569e1236e54 Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Mon, 26 May 2025 20:39:51 +0530 Subject: [PATCH] feat: extend platform API support for experimental scripting sandbox (#5097) --- .../src/__tests__/e2e/commands/test.spec.ts | 38 ++++ .../test-scripting-sandbox-modes-coll.json | 34 ++++ packages/hoppscotch-js-sandbox/package.json | 2 +- .../src/bootstrap-code/post-request.js | 73 +++---- .../src/cage-modules/default.ts | 68 +++++++ .../src/cage-modules/index.ts | 2 + .../src/cage-modules/pw.ts | 6 +- .../src/node/pre-request.ts | 165 ---------------- .../src/node/pre-request/experimental.ts | 47 +++++ .../src/node/pre-request/index.ts | 14 ++ .../src/node/pre-request/legacy.ts | 82 ++++++++ .../src/node/test-runner/experimental.ts | 59 ++++++ .../src/node/test-runner/index.ts | 24 +++ .../{test-runner.ts => test-runner/legacy.ts} | 187 +++++------------- .../hoppscotch-js-sandbox/src/shared-utils.ts | 6 +- .../src/web/pre-request/index.ts | 126 +++++------- .../src/web/test-runner/index.ts | 157 ++++++--------- pnpm-lock.yaml | 11 +- 18 files changed, 560 insertions(+), 541 deletions(-) create mode 100644 packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json create mode 100644 packages/hoppscotch-js-sandbox/src/cage-modules/default.ts create mode 100644 packages/hoppscotch-js-sandbox/src/cage-modules/index.ts delete mode 100644 packages/hoppscotch-js-sandbox/src/node/pre-request.ts create mode 100644 packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts create mode 100644 packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts create mode 100644 packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts create mode 100644 packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts create mode 100644 packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts rename packages/hoppscotch-js-sandbox/src/node/{test-runner.ts => test-runner/legacy.ts} (61%) diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index 3de3367f..71a2ce91 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -180,6 +180,44 @@ describe("hopp test [options] ", { timeout: 100000 }, () => { expect(error).toBeNull(); }); }); + + test("Successfully display console logs and recognizes platform APIs in the experimental scripting sandbox", async () => { + const args = `test ${getTestJsonFilePath( + "test-scripting-sandbox-modes-coll.json", + "collection" + )}`; + const { error, stdout } = await runCLI(args); + + expect(error).toBeNull(); + + const expectedStaticParts = [ + "https://example.com/path?foo=bar&baz=qux", + "'0': 72", + "'12': 33", + "Decoded: Hello, world!", + "Hello after 1s", + ]; + + // Assert that each stable part appears in the output + expectedStaticParts.forEach((part) => { + expect(stdout).toContain(part); + }); + + const every500msCount = (stdout.match(/Every 500ms/g) || []).length; + expect(every500msCount).toBe(3); + }); + + test("Fails to display console logs and recognize platform APIs in the legacy scripting sandbox", async () => { + const args = `test ${getTestJsonFilePath( + "test-scripting-sandbox-modes-coll.json", + "collection" + )} --legacy-sandbox`; + const { error, stdout } = await runCLI(args); + + expect(error).toBeTruthy(); + expect(stdout).not.toContain("https://example.com/path?foo=bar&baz=qux"); + expect(stdout).not.toContain("Encoded"); + }); }); test("Ensures tests run in sequence order based on request path", async () => { diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json new file mode 100644 index 00000000..548b9cef --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/test-scripting-sandbox-modes-coll.json @@ -0,0 +1,34 @@ +{ + "v": 7, + "id": "cmb4vtsqh00nxwvqryk6jmnaz", + "name": "test-scripting-sandbox-modes", + "folders": [], + "requests": [ + { + "v": "12", + "name": "sample-req", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "const url = new URL('https://example.com/path?foo=bar');\nurl.searchParams.set('baz', 'qux');\nurl.toString(); // 'https://example.com/path?foo=bar&baz=qux'\n\nconsole.debug(url)\n\nconst encoder = new TextEncoder();\n\nconst text = \"Hello, world!\";\n\nconst encoded = encoder.encode(text);\nconsole.log(\"Encoded:\", encoded);\n\nconst decoder = new TextDecoder();\n\nconst decoded = decoder.decode(encoded);\nconsole.log(\"Decoded:\", decoded);\n\nsetTimeout(() => console.log(\"Hello after 1s\"), 1000);\n \nconst intervalId = setInterval(() => console.log(\"Every 500ms\"), 500);\n\nsetTimeout(() => clearInterval(intervalId), 2000);", + "testScript": "console.log(JSON.stringify(pw, null, 2))\n\n\npw.test(\"Sample assertion\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx()\n \tconsole.log(\"Status code received is \", pw.response.status)\n});\n\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "_ref_id": "coll_mb4vvacd_e015964c-b5c8-4b90-a564-4a8b62bc1631" +} diff --git a/packages/hoppscotch-js-sandbox/package.json b/packages/hoppscotch-js-sandbox/package.json index d2c5c76d..2df22f8f 100644 --- a/packages/hoppscotch-js-sandbox/package.json +++ b/packages/hoppscotch-js-sandbox/package.json @@ -53,7 +53,7 @@ "dependencies": { "@hoppscotch/data": "workspace:^", "@types/lodash-es": "4.17.12", - "faraday-cage": "0.0.15", + "faraday-cage": "0.0.16", "fp-ts": "2.16.9", "lodash": "4.17.21", "lodash-es": "4.17.21" 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 52811283..a996d5d6 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js @@ -9,52 +9,37 @@ resolve: (key) => inputs.envResolve(key), }, expect: (expectVal) => { - return { + const isDateInstance = expectVal instanceof Date + + const expectation = { toBe: (expectedVal) => inputs.expectToBe(expectVal, expectedVal), - toBeLevel2xx: (expectedVal) => - inputs.expectToBeLevel2xx(expectVal, expectedVal), - toBeLevel3xx: (expectedVal) => - inputs.expectToBeLevel3xx(expectVal, expectedVal), - toBeLevel4xx: (expectedVal) => - inputs.expectToBeLevel4xx(expectVal, expectedVal), - toBeLevel5xx: (expectedVal) => - inputs.expectToBeLevel5xx(expectVal, expectedVal), - toBeType: (expectedVal) => { - const isExpectValDateInstance = expectVal instanceof Date - return inputs.expectToBeType( - expectVal, - expectedVal, - isExpectValDateInstance - ) - }, - toHaveLength: (expectedVal) => - inputs.expectToHaveLength(expectVal, expectedVal), - toInclude: (expectedVal) => - inputs.expectToInclude(expectVal, expectedVal), - not: { - toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal), - toBeLevel2xx: (expectedVal) => - inputs.expectNotToBeLevel2xx(expectVal, expectedVal), - toBeLevel3xx: (expectedVal) => - inputs.expectNotToBeLevel3xx(expectVal, expectedVal), - toBeLevel4xx: (expectedVal) => - inputs.expectNotToBeLevel4xx(expectVal, expectedVal), - toBeLevel5xx: (expectedVal) => - inputs.expectNotToBeLevel5xx(expectVal, expectedVal), - toBeType: (expectedVal) => { - const isExpectValDateInstance = expectVal instanceof Date - return inputs.expectNotToBeType( - expectVal, - expectedVal, - isExpectValDateInstance - ) - }, - toHaveLength: (expectedVal) => - inputs.expectNotToHaveLength(expectVal, expectedVal), - toInclude: (expectedVal) => - inputs.expectNotToInclude(expectVal, expectedVal), - }, + toBeLevel2xx: () => inputs.expectToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectToInclude(expectVal, needle), } + + Object.defineProperty(expectation, "not", { + get: () => ({ + toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectNotToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectNotToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectNotToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectNotToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectNotToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectNotToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectNotToInclude(expectVal, needle), + }), + }) + + return expectation }, test: (descriptor, testFn) => { inputs.preTest(descriptor) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts new file mode 100644 index 00000000..4a4dc7ab --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts @@ -0,0 +1,68 @@ +import { + blobPolyfill, + ConsoleEntry, + console as ConsoleModule, + crypto, + encoding, + esmModuleLoader, + fetch, + urlPolyfill, + timers, +} from "faraday-cage/modules" + +type DefaultModulesConfig = { + handleConsoleEntry: (consoleEntries: ConsoleEntry) => void +} + +export const defaultModules = (config?: DefaultModulesConfig) => { + return [ + urlPolyfill, + blobPolyfill, + ConsoleModule({ + onLog(level, ...args) { + console[level](...args) + + config?.handleConsoleEntry({ + type: level, + args, + timestamp: Date.now(), + }) + }, + onCount(...args) { + console.count(args[0]) + }, + onTime(...args) { + console.timeEnd(args[0]) + }, + onTimeLog(...args) { + console.timeLog(...args) + }, + onGroup(...args) { + console.group(...args) + }, + onGroupEnd(...args) { + console.groupEnd(...args) + }, + onClear(...args) { + console.clear(...args) + }, + onAssert(...args) { + console.assert(...args) + }, + onDir(...args) { + console.dir(...args) + }, + onTable(...args) { + console.table(...args) + }, + }), + crypto({ + cryptoImpl: globalThis.crypto, + }), + + esmModuleLoader, + fetch(), + encoding(), + timers(), + ] +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts new file mode 100644 index 00000000..d8a9d08c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts @@ -0,0 +1,2 @@ +export { defaultModules } from "./default" +export { pwPostRequestModule, pwPreRequestModule } from "./pw" diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/pw.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/pw.ts index 7f7347c3..46a8125b 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/pw.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/pw.ts @@ -261,8 +261,8 @@ const createPwModule = ( }) } -export const pwPostRequestModule = (config: PwPostRequestModuleConfig) => - createPwModule("post", postRequestBootstrapCode, config) - export const pwPreRequestModule = (config: PwPreRequestModuleConfig) => createPwModule("pre", preRequestBootstrapCode, config) + +export const pwPostRequestModule = (config: PwPostRequestModuleConfig) => + createPwModule("post", postRequestBootstrapCode, config) diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request.ts deleted file mode 100644 index b0ac594a..00000000 --- a/packages/hoppscotch-js-sandbox/src/node/pre-request.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { FaradayCage } from "faraday-cage" -import { - blobPolyfill, - console as ConsoleModule, - crypto, - esmModuleLoader, - fetch, -} from "faraday-cage/modules" -import { pipe } from "fp-ts/function" -import * as TE from "fp-ts/lib/TaskEither" -import type ivmT from "isolated-vm" -import { cloneDeep } from "lodash" -import { pwPreRequestModule } from "~/cage-modules/pw" - -import { createRequire } from "module" -import { TestResult } from "~/types" - -import { getPreRequestScriptMethods } from "~/shared-utils" -import { getSerializedAPIMethods } from "./utils" - -const nodeRequire = createRequire(import.meta.url) -const ivm = nodeRequire("isolated-vm") - -export const runPreRequestScript = ( - preRequestScript: string, - envs: TestResult["envs"], - experimentalScriptingSandbox = true -): TE.TaskEither => { - if (!experimentalScriptingSandbox) { - return pipe( - TE.tryCatch( - async () => { - const isolate: ivmT.Isolate = new ivm.Isolate() - const context = await isolate.createContext() - return { isolate, context } - }, - (reason) => `Context initialization failed: ${reason}` - ), - TE.chain(({ isolate, context }) => - pipe( - TE.tryCatch( - async () => { - const jail = context.global - const { pw, updatedEnvs } = getPreRequestScriptMethods(envs) - const serializedAPIMethods = getSerializedAPIMethods(pw) - jail.setSync("serializedAPIMethods", serializedAPIMethods, { - copy: true, - }) - jail.setSync("atob", atob) - jail.setSync("btoa", btoa) - // Methods in the isolate context can't be invoked straightaway - const finalScript = ` - const pw = new Proxy(serializedAPIMethods, { - get: (pwObjTarget, pwObjProp) => { - const topLevelEntry = pwObjTarget[pwObjProp] - // "pw.env" set of API methods - if (topLevelEntry && typeof topLevelEntry === "object") { - return new Proxy(topLevelEntry, { - get: (subTarget, subProp) => { - const subLevelProperty = subTarget[subProp] - if (subLevelProperty && subLevelProperty.typeof === "function") { - return (...args) => subLevelProperty.applySync(null, args) - } - }, - }) - } - } - }) - ${preRequestScript} - ` - // Create a script and compile it - const script = await isolate.compileScript(finalScript) - // Run the pre-request script in the provided context - await script.run(context) - return updatedEnvs - }, - (reason) => reason - ), - TE.fold( - (error) => TE.left(`Script execution failed: ${error}`), - (result) => - pipe( - TE.tryCatch( - async () => { - await isolate.dispose() - return result - }, - (disposeError) => `Isolate disposal failed: ${disposeError}` - ) - ) - ) - ) - ) - ) - } - - return pipe( - TE.tryCatch( - async (): Promise => { - let finalEnvs = envs - - const cage = await FaradayCage.create() - - const result = await cage.runCode(preRequestScript, [ - pwPreRequestModule({ - envs: cloneDeep(envs), - handleSandboxResults: ({ envs }) => { - finalEnvs = envs - }, - }), - blobPolyfill, - ConsoleModule({ - onLog(...args) { - console[args[0]](...args) - }, - onCount(...args) { - console.count(args[0]) - }, - onTime(...args) { - console.timeEnd(args[0]) - }, - onTimeLog(...args) { - console.timeLog(...args) - }, - onGroup(...args) { - console.group(...args) - }, - onGroupEnd(...args) { - console.groupEnd(...args) - }, - onClear(...args) { - console.clear(...args) - }, - onAssert(...args) { - console.assert(...args) - }, - onDir(...args) { - console.dir(...args) - }, - onTable(...args) { - console.table(...args) - }, - }), - crypto(), - esmModuleLoader, - fetch(), - ]) - - if (result.type === "error") { - throw result.err - } - - return finalEnvs - }, - (error) => { - if (error !== null && typeof error === "object" && "message" in error) { - const reason = `${"name" in error ? error.name : ""}: ${error.message}` - return `Script execution failed: ${reason}` - } - - return `Script execution failed: ${String(error)}` - } - ) - ) -} diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts new file mode 100644 index 00000000..51b0b22a --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts @@ -0,0 +1,47 @@ +import { FaradayCage } from "faraday-cage" +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/lib/TaskEither" +import { cloneDeep } from "lodash" + +import { defaultModules, pwPreRequestModule } from "~/cage-modules" +import { TestResult } from "~/types" + +export const runPreRequestScriptWithFaradayCage = ( + preRequestScript: string, + envs: TestResult["envs"] +): TE.TaskEither => { + return pipe( + TE.tryCatch( + async (): Promise => { + let finalEnvs = envs + + const cage = await FaradayCage.create() + + const result = await cage.runCode(preRequestScript, [ + ...defaultModules(), + + pwPreRequestModule({ + envs: cloneDeep(envs), + handleSandboxResults: ({ envs }) => { + finalEnvs = envs + }, + }), + ]) + + if (result.type === "error") { + throw result.err + } + + return finalEnvs + }, + (error) => { + if (error !== null && typeof error === "object" && "message" in error) { + const reason = `${"name" in error ? error.name : ""}: ${error.message}` + return `Script execution failed: ${reason}` + } + + return `Script execution failed: ${String(error)}` + } + ) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts new file mode 100644 index 00000000..4d087785 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/index.ts @@ -0,0 +1,14 @@ +import * as TE from "fp-ts/lib/TaskEither" +import { TestResult } from "~/types" + +import { runPreRequestScriptWithFaradayCage } from "./experimental" +import { runPreRequestScriptWithIsolatedVm } from "./legacy" + +export const runPreRequestScript = ( + preRequestScript: string, + envs: TestResult["envs"], + experimentalScriptingSandbox = true +): TE.TaskEither => + experimentalScriptingSandbox + ? runPreRequestScriptWithFaradayCage(preRequestScript, envs) + : runPreRequestScriptWithIsolatedVm(preRequestScript, envs) diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts new file mode 100644 index 00000000..13e5ed77 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/legacy.ts @@ -0,0 +1,82 @@ +import { pipe } from "fp-ts/function" +import * as TE from "fp-ts/lib/TaskEither" +import type ivmT from "isolated-vm" +import { createRequire } from "module" + +import { getPreRequestScriptMethods } from "~/shared-utils" +import { TestResult } from "~/types" +import { getSerializedAPIMethods } from "../utils" + +const nodeRequire = createRequire(import.meta.url) +const ivm = nodeRequire("isolated-vm") + +export const runPreRequestScriptWithIsolatedVm = ( + preRequestScript: string, + envs: TestResult["envs"] +): TE.TaskEither => { + return pipe( + TE.tryCatch( + async () => { + const isolate: ivmT.Isolate = new ivm.Isolate() + const context = await isolate.createContext() + return { isolate, context } + }, + (reason) => `Context initialization failed: ${reason}` + ), + TE.chain(({ isolate, context }) => + pipe( + TE.tryCatch( + async () => { + const jail = context.global + const { pw, updatedEnvs } = getPreRequestScriptMethods(envs) + const serializedAPIMethods = getSerializedAPIMethods(pw) + jail.setSync("serializedAPIMethods", serializedAPIMethods, { + copy: true, + }) + jail.setSync("atob", atob) + jail.setSync("btoa", btoa) + // Methods in the isolate context can't be invoked straightaway + const finalScript = ` + const pw = new Proxy(serializedAPIMethods, { + get: (pwObjTarget, pwObjProp) => { + const topLevelEntry = pwObjTarget[pwObjProp] + // "pw.env" set of API methods + if (topLevelEntry && typeof topLevelEntry === "object") { + return new Proxy(topLevelEntry, { + get: (subTarget, subProp) => { + const subLevelProperty = subTarget[subProp] + if (subLevelProperty && subLevelProperty.typeof === "function") { + return (...args) => subLevelProperty.applySync(null, args) + } + }, + }) + } + } + }) + ${preRequestScript} + ` + // Create a script and compile it + const script = await isolate.compileScript(finalScript) + // Run the pre-request script in the provided context + await script.run(context) + return updatedEnvs + }, + (reason) => reason + ), + TE.fold( + (error) => TE.left(`Script execution failed: ${error}`), + (result) => + pipe( + TE.tryCatch( + async () => { + await isolate.dispose() + return result + }, + (disposeError) => `Isolate disposal failed: ${disposeError}` + ) + ) + ) + ) + ) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts new file mode 100644 index 00000000..0df5dd87 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts @@ -0,0 +1,59 @@ +import { FaradayCage } from "faraday-cage" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { cloneDeep } from "lodash" + +import { defaultModules, pwPostRequestModule } from "~/cage-modules" +import { TestDescriptor, TestResponse, TestResult } from "~/types" + +export const runTestScriptWithFaradayCage = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse +): TE.TaskEither => { + return pipe( + TE.tryCatch( + async (): Promise => { + const testRunStack: TestDescriptor[] = [ + { descriptor: "root", expectResults: [], children: [] }, + ] + + let finalEnvs = envs + let finalTestResults = testRunStack + + const cage = await FaradayCage.create() + + const result = await cage.runCode(testScript, [ + ...defaultModules(), + + pwPostRequestModule({ + envs: cloneDeep(envs), + testRunStack: cloneDeep(testRunStack), + response, + handleSandboxResults: ({ envs, testRunStack }) => { + finalEnvs = envs + finalTestResults = testRunStack + }, + }), + ]) + + if (result.type === "error") { + throw result.err + } + + return { + tests: finalTestResults, + envs: finalEnvs, + } + }, + (error) => { + if (error !== null && typeof error === "object" && "message" in error) { + const reason = `${"name" in error ? error.name : ""}: ${error.message}` + return `Script execution failed: ${reason}` + } + + return `Script execution failed: ${String(error)}` + } + ) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts new file mode 100644 index 00000000..1f948982 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts @@ -0,0 +1,24 @@ +import * as E from "fp-ts/Either" +import * as TE from "fp-ts/TaskEither" + +import { preventCyclicObjects } from "~/shared-utils" +import { TestResponse, TestResult } from "~/types" +import { runTestScriptWithFaradayCage } from "./experimental" +import { runTestScriptWithIsolatedVm } from "./legacy" + +export const runTestScript = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse, + experimentalScriptingSandbox = true +): TE.TaskEither => { + const responseObjHandle = preventCyclicObjects(response) + + if (E.isLeft(responseObjHandle)) { + return TE.left(`Response marshalling failed: ${responseObjHandle.left}`) + } + + return experimentalScriptingSandbox + ? runTestScriptWithFaradayCage(testScript, envs, responseObjHandle.right) + : runTestScriptWithIsolatedVm(testScript, envs, responseObjHandle.right) +} diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/legacy.ts similarity index 61% rename from packages/hoppscotch-js-sandbox/src/node/test-runner.ts rename to packages/hoppscotch-js-sandbox/src/node/test-runner/legacy.ts index 6ebb0dd8..279e85a2 100644 --- a/packages/hoppscotch-js-sandbox/src/node/test-runner.ts +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/legacy.ts @@ -1,159 +1,19 @@ -import { FaradayCage } from "faraday-cage" -import { - blobPolyfill, - console as ConsoleModule, - crypto, - esmModuleLoader, - fetch, -} from "faraday-cage/modules" import * as E from "fp-ts/Either" import * as TE from "fp-ts/TaskEither" import { pipe } from "fp-ts/function" import type ivmT from "isolated-vm" -import { cloneDeep } from "lodash" import { createRequire } from "module" -import { pwPostRequestModule } from "~/cage-modules/pw" import { getTestRunnerScriptMethods, preventCyclicObjects, } from "~/shared-utils" -import { TestDescriptor, TestResponse, TestResult } from "~/types" -import { getSerializedAPIMethods } from "./utils" +import { TestResponse, TestResult } from "~/types" +import { getSerializedAPIMethods } from "../utils" const nodeRequire = createRequire(import.meta.url) const ivm = nodeRequire("isolated-vm") -export const runTestScript = ( - testScript: string, - envs: TestResult["envs"], - response: TestResponse, - experimentalScriptingSandbox = true -): TE.TaskEither => { - const responseObjHandle = preventCyclicObjects(response) - - if (E.isLeft(responseObjHandle)) { - return TE.left(`Response marshalling failed: ${responseObjHandle.left}`) - } - - if (!experimentalScriptingSandbox) { - return pipe( - TE.tryCatch( - async () => { - const isolate: ivmT.Isolate = new ivm.Isolate() - const context = await isolate.createContext() - return { isolate, context } - }, - (reason) => `Context initialization failed: ${reason}` - ), - TE.chain(({ isolate, context }) => - pipe( - TE.tryCatch( - async () => - executeScriptInContext( - testScript, - envs, - response, - isolate, - context - ), - (reason) => `Script execution failed: ${reason}` - ), - TE.chain((result) => - TE.tryCatch( - async () => { - await isolate.dispose() - return result - }, - (disposeReason) => `Isolate disposal failed: ${disposeReason}` - ) - ) - ) - ) - ) - } - - return pipe( - TE.tryCatch( - async (): Promise => { - const testRunStack: TestDescriptor[] = [ - { descriptor: "root", expectResults: [], children: [] }, - ] - - let finalEnvs = envs - let finalTestResults = testRunStack - - const cage = await FaradayCage.create() - - const result = await cage.runCode(testScript, [ - pwPostRequestModule({ - envs: cloneDeep(envs), - testRunStack: cloneDeep(testRunStack), - response, - handleSandboxResults: ({ envs, testRunStack }) => { - finalEnvs = envs - finalTestResults = testRunStack - }, - }), - blobPolyfill, - ConsoleModule({ - onLog(...args) { - console[args[0]](...args) - }, - onCount(...args) { - console.count(args[0]) - }, - onTime(...args) { - console.timeEnd(args[0]) - }, - onTimeLog(...args) { - console.timeLog(...args) - }, - onGroup(...args) { - console.group(...args) - }, - onGroupEnd(...args) { - console.groupEnd(...args) - }, - onClear(...args) { - console.clear(...args) - }, - onAssert(...args) { - console.assert(...args) - }, - onDir(...args) { - console.dir(...args) - }, - onTable(...args) { - console.table(...args) - }, - }), - crypto(), - esmModuleLoader, - fetch(), - ]) - - if (result.type === "error") { - throw result.err - } - - return { - tests: finalTestResults, - envs: finalEnvs, - } - }, - (error) => { - if (error !== null && typeof error === "object" && "message" in error) { - const reason = `${"name" in error ? error.name : ""}: ${error.message}` - return `Script execution failed: ${reason}` - } - - return `Script execution failed: ${String(error)}` - } - ) - ) -} - const executeScriptInContext = ( testScript: string, envs: TestResult["envs"], @@ -163,7 +23,7 @@ const executeScriptInContext = ( ): Promise => { return new Promise((resolve, reject) => { // Parse response object - const responseObjHandle = preventCyclicObjects(response) + const responseObjHandle = preventCyclicObjects(response) if (E.isLeft(responseObjHandle)) { return reject(`Response parsing failed: ${responseObjHandle.left}`) } @@ -315,3 +175,44 @@ const executeScriptInContext = ( }) }) } + +export const runTestScriptWithIsolatedVm = ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse +): TE.TaskEither => { + return pipe( + TE.tryCatch( + async () => { + const isolate: ivmT.Isolate = new ivm.Isolate() + const context = await isolate.createContext() + return { isolate, context } + }, + (reason) => `Context initialization failed: ${reason}` + ), + TE.chain(({ isolate, context }) => + pipe( + TE.tryCatch( + async () => + executeScriptInContext( + testScript, + envs, + response, + isolate, + context + ), + (reason) => `Script execution failed: ${reason}` + ), + TE.chain((result) => + TE.tryCatch( + async () => { + await isolate.dispose() + return result + }, + (disposeReason) => `Isolate disposal failed: ${disposeReason}` + ) + ) + ) + ) + ) +} diff --git a/packages/hoppscotch-js-sandbox/src/shared-utils.ts b/packages/hoppscotch-js-sandbox/src/shared-utils.ts index 038f174b..802b6bfb 100644 --- a/packages/hoppscotch-js-sandbox/src/shared-utils.ts +++ b/packages/hoppscotch-js-sandbox/src/shared-utils.ts @@ -213,9 +213,9 @@ const getResolvedExpectValue = (expectVal: any) => { } } -export function preventCyclicObjects( - obj: Record -): E.Left | E.Right> { +export function preventCyclicObjects>( + obj: T +): E.Left | E.Right { let jsonString try { diff --git a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts index 1dc1f241..e12ac2d7 100644 --- a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts @@ -1,97 +1,52 @@ +import { FaradayCage } from "faraday-cage" +import { ConsoleEntry } from "faraday-cage/modules" import * as E from "fp-ts/Either" - +import { cloneDeep } from "lodash" import { SandboxPreRequestResult, TestResult } from "~/types" -import { FaradayCage } from "faraday-cage" -import { - blobPolyfill, - ConsoleEntry, - console as ConsoleModule, - crypto, - esmModuleLoader, - fetch, -} from "faraday-cage/modules" -import { cloneDeep } from "lodash" - -import * as TE from "fp-ts/lib/TaskEither" -import { pwPreRequestModule } from "~/cage-modules/pw" +import { defaultModules, pwPreRequestModule } from "~/cage-modules" import Worker from "./worker?worker&inline" -export const runPreRequestScript = async ( +const runPreRequestScriptWithWebWorker = ( preRequestScript: string, - envs: TestResult["envs"], - experimentalScriptingSandbox = true + envs: TestResult["envs"] +): Promise> => { + return new Promise((resolve) => { + const worker = new Worker() + + // Listen for the results from the web worker + worker.addEventListener("message", (event: MessageEvent) => { + worker.terminate() + return resolve(event.data.results) + }) + + // Send the script to the web worker + worker.postMessage({ + preRequestScript, + envs, + }) + }) +} + +const runPreRequestScriptWithFaradayCage = async ( + preRequestScript: string, + envs: TestResult["envs"] ): Promise> => { const consoleEntries: ConsoleEntry[] = [] let finalEnvs = envs - if (!experimentalScriptingSandbox) { - return new Promise((resolve) => { - const worker = new Worker() - - // Listen for the results from the web worker - worker.addEventListener("message", (event: MessageEvent) => - resolve(event.data.results) - ) - - // Send the script to the web worker - worker.postMessage({ - preRequestScript, - envs, - }) - }) - } - const cage = await FaradayCage.create() const result = await cage.runCode(preRequestScript, [ + ...defaultModules({ + handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry), + }), + pwPreRequestModule({ envs: cloneDeep(envs), - handleSandboxResults: ({ envs }) => { - finalEnvs = envs - }, + handleSandboxResults: ({ envs }) => (finalEnvs = envs), }), - blobPolyfill, - ConsoleModule({ - onLog(...args) { - console[args[0]](...args) - }, - onCount(...args) { - console.count(args[0]) - }, - onTime(...args) { - console.timeEnd(args[0]) - }, - onTimeLog(...args) { - console.timeLog(...args) - }, - onGroup(...args) { - console.group(...args) - }, - onGroupEnd(...args) { - console.groupEnd(...args) - }, - onClear(...args) { - console.clear(...args) - }, - onAssert(...args) { - console.assert(...args) - }, - onDir(...args) { - console.dir(...args) - }, - onTable(...args) { - console.table(...args) - }, - onFinish(entries) { - consoleEntries.push(...entries) - }, - }), - crypto(), - esmModuleLoader, - fetch(), - esmModuleLoader, ]) if (result.type === "error") { @@ -100,14 +55,23 @@ export const runPreRequestScript = async ( typeof result.err === "object" && "message" in result.err ) { - return TE.left(`Script execution failed: ${result.err.message}`)() + return E.left(`Script execution failed: ${result.err.message}`) } - return TE.left(`Script execution failed: ${String(result.err)}`)() + return E.left(`Script execution failed: ${String(result.err)}`) } - return TE.right({ + return E.right({ envs: finalEnvs, consoleEntries, - })() + }) } + +export const runPreRequestScript = async ( + preRequestScript: string, + envs: TestResult["envs"], + experimentalScriptingSandbox = true +): Promise> => + experimentalScriptingSandbox + ? runPreRequestScriptWithFaradayCage(preRequestScript, envs) + : runPreRequestScriptWithWebWorker(preRequestScript, envs) diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts index 19a2e7fa..30409122 100644 --- a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts @@ -1,5 +1,10 @@ +import { FaradayCage } from "faraday-cage" +import { ConsoleEntry } from "faraday-cage/modules" import * as E from "fp-ts/Either" +import { cloneDeep } from "lodash-es" +import { defaultModules, pwPostRequestModule } from "~/cage-modules" +import { preventCyclicObjects } from "~/shared-utils" import { SandboxTestResult, TestDescriptor, @@ -7,30 +12,34 @@ import { TestResult, } from "~/types" -import { - blobPolyfill, - ConsoleEntry, - console as ConsoleModule, - crypto, - esmModuleLoader, - fetch, -} from "faraday-cage/modules" - -import { FaradayCage } from "faraday-cage" - -import * as TE from "fp-ts/lib/TaskEither" - -import { cloneDeep } from "lodash-es" -import { pwPostRequestModule } from "~/cage-modules/pw" -import { preventCyclicObjects } from "~/shared-utils" - import Worker from "./worker?worker&inline" -export const runTestScript = async ( +const runTestScriptWithWebWorker = ( testScript: string, envs: TestResult["envs"], - response: TestResponse, - experimentalScriptingSandbox = true + response: TestResponse +): Promise> => { + return new Promise((resolve) => { + const worker = new Worker() + + // Listen for the results from the web worker + worker.addEventListener("message", (event: MessageEvent) => + resolve(event.data.results) + ) + + // Send the script to the web worker + worker.postMessage({ + testScript, + envs, + response, + }) + }) +} + +const runTestScriptWithFaradayCage = async ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse ): Promise> => { const testRunStack: TestDescriptor[] = [ { descriptor: "root", expectResults: [], children: [] }, @@ -40,100 +49,56 @@ export const runTestScript = async ( let finalTestResults = testRunStack const consoleEntries: ConsoleEntry[] = [] - const responseObjHandle = preventCyclicObjects(response) - - if (E.isLeft(responseObjHandle)) { - return TE.left(`Response marshalling failed: ${responseObjHandle.left}`)() - } - - if (!experimentalScriptingSandbox) { - return new Promise((resolve) => { - const worker = new Worker() - - // Listen for the results from the web worker - worker.addEventListener("message", (event: MessageEvent) => - resolve(event.data.results) - ) - - // Send the script to the web worker - worker.postMessage({ - testScript, - envs, - response, - }) - }) - } - const cage = await FaradayCage.create() const result = await cage.runCode(testScript, [ + ...defaultModules({ + handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry), + }), + pwPostRequestModule({ envs: cloneDeep(envs), testRunStack: cloneDeep(testRunStack), - response: responseObjHandle.right as TestResponse, + response, handleSandboxResults: ({ envs, testRunStack }) => { finalEnvs = envs finalTestResults = testRunStack }, }), - blobPolyfill, - ConsoleModule({ - onLog(...args) { - console[args[0]](...args.slice(1)) - }, - onCount(...args) { - console.count(args[0]) - }, - onTime(...args) { - console.timeEnd(args[0]) - }, - onTimeLog(...args) { - console.timeLog(...args) - }, - onGroup(...args) { - console.group(...args) - }, - onGroupEnd(...args) { - console.groupEnd(...args) - }, - onClear(...args) { - console.clear(...args) - }, - onAssert(...args) { - console.assert(...args) - }, - onDir(...args) { - console.dir(...args) - }, - onTable(...args) { - console.table(...args) - }, - onFinish(entries) { - consoleEntries.push(...entries) - }, - }), - crypto(), - esmModuleLoader, - fetch(), ]) if (result.type === "error") { - if (result.type === "error") { - if ( - result.err !== null && - typeof result.err === "object" && - "message" in result.err - ) { - return TE.left(`Script execution failed: ${result.err.message}`)() - } - - return TE.left(`Script execution failed: ${String(result.err)}`)() + if ( + result.err !== null && + typeof result.err === "object" && + "message" in result.err + ) { + return E.left(`Script execution failed: ${result.err.message}`) } + + return E.left(`Script execution failed: ${String(result.err)}`) } - return TE.right({ + return E.right({ tests: finalTestResults[0], envs: finalEnvs, consoleEntries, - })() + }) +} + +export const runTestScript = async ( + testScript: string, + envs: TestResult["envs"], + response: TestResponse, + experimentalScriptingSandbox = true +): Promise> => { + const responseObjHandle = preventCyclicObjects(response) + + if (E.isLeft(responseObjHandle)) { + return E.left(`Response marshalling failed: ${responseObjHandle.left}`) + } + + return experimentalScriptingSandbox + ? runTestScriptWithFaradayCage(testScript, envs, responseObjHandle.right) + : runTestScriptWithWebWorker(testScript, envs, responseObjHandle.right) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34b5527a..90953025 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1133,8 +1133,8 @@ importers: specifier: 4.17.12 version: 4.17.12 faraday-cage: - specifier: 0.0.15 - version: 0.0.15 + specifier: 0.0.16 + version: 0.0.16 fp-ts: specifier: 2.16.9 version: 2.16.9 @@ -8859,8 +8859,8 @@ packages: resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==} engines: {node: ^12.20 || >= 14.13} - faraday-cage@0.0.15: - resolution: {integrity: sha512-ZERKHFsea2fZ0gfDVFOkXmtryhp+7CyUBFaa9nvRq7Gbf6XVnZ0f0dKsZjwoBzYqSD3Lhl2DFfK8bW4PEo1y0g==} + faraday-cage@0.0.16: + resolution: {integrity: sha512-+BHq/8LnqSsZ+qygj4+dgdis3iR+bBtB5dbWcw8ZNhdhoYQ+X9FS00qhQ6B6tIoEBNBHBSeQN3ydMXOjoE0y3Q==} fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -10938,6 +10938,7 @@ packages: multer@1.4.5-lts.2: resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==} engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -24439,7 +24440,7 @@ snapshots: extract-files@11.0.0: {} - faraday-cage@0.0.15: + faraday-cage@0.0.16: dependencies: '@jitl/quickjs-ffi-types': 0.31.0 '@jitl/quickjs-singlefile-mjs-release-asyncify': 0.31.0