refactor(scripting-revamp): migrate js-sandbox to web worker/Node vm based implementation (#3619)

This commit is contained in:
James George
2023-12-07 16:10:42 +05:30
committed by GitHub
parent 0a61ec2bfe
commit bdfa14fa54
43 changed files with 2805 additions and 3285 deletions

View File

@@ -147,7 +147,7 @@ module.exports = {
// The glob patterns Jest uses to detect test files
testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
"**/src/__tests__/**/*.*.ts",
"**/src/__tests__/commands/**/*.*.ts",
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped

View File

@@ -19,8 +19,9 @@
"debugger": "node debugger.js 9999",
"prepublish": "pnpm exec tsup",
"prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write",
"test": "pnpm run build && jest && rm -rf dist",
"do-typecheck": "pnpm exec tsc --noEmit",
"test": "pnpm run build && jest && rm -rf dist"
"do-test": "pnpm test"
},
"keywords": [
"cli",

View File

@@ -28,7 +28,7 @@ describe("Test 'hopp test <file>' command:", () => {
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
});
test("Malformed collection file.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
"malformed-collection2.json"
@@ -106,7 +106,7 @@ describe("Test 'hopp test <file> --env <file>' command:", () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json");
const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error } = await execAsync(cmd);
const { error, stdout } = await execAsync(cmd);
expect(error).toBeNull();
});
@@ -129,7 +129,6 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
console.log("invalid value thing", out)
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});

View File

@@ -1,7 +1,6 @@
{
"URL": "https://echo.hoppscotch.io",
"HOST": "echo.hoppscotch.io",
"X-COUNTRY": "IN",
"BODY_VALUE": "body_value",
"BODY_KEY": "body_key"
}

View File

@@ -12,7 +12,7 @@
"method": "POST",
"auth": { "authType": "none", "authActive": true },
"preRequestScript": "",
"testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst X_COUNTRY = pw.env.get(\"X-COUNTRY\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n\t pw.expect(pw.response.body.headers[\"x-country\"]).toBe(X_COUNTRY); \n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})",
"testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})",
"body": {
"contentType": "application/json",
"body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}"

View File

@@ -6,23 +6,24 @@ import {
parseTemplateString,
parseTemplateStringE,
} from "@hoppscotch/data";
import { runPreRequestScript } from "@hoppscotch/js-sandbox";
import { flow, pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import * as E from "fp-ts/Either";
import * as RA from "fp-ts/ReadonlyArray";
import { runPreRequestScript } from "@hoppscotch/js-sandbox/node";
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import * as O from "fp-ts/Option";
import * as RA from "fp-ts/ReadonlyArray";
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 { EffectiveHoppRESTRequest } from "../interfaces/request";
import { error, HoppCLIError } from "../types/errors";
import { HoppCLIError, error } from "../types/errors";
import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "./checks";
import { tupleToRecord, arraySort, arrayFlatMap } from "./functions/array";
import { toFormData } from "./mutators";
import { getEffectiveFinalMetaData } from "./getters";
import { PreRequestMetrics } from "../types/response";
import { isHoppCLIError } from "./checks";
import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array";
import { getEffectiveFinalMetaData } from "./getters";
import { toFormData } from "./mutators";
/**
* Runs pre-request-script runner over given request which extracts set ENVs and

View File

@@ -1,17 +1,19 @@
import { HoppRESTRequest } from "@hoppscotch/data";
import { execTestScript, TestDescriptor } from "@hoppscotch/js-sandbox";
import { hrtime } from "process";
import { flow, pipe } from "fp-ts/function";
import * as RA from "fp-ts/ReadonlyArray";
import { TestDescriptor } from "@hoppscotch/js-sandbox";
import { runTestScript } from "@hoppscotch/js-sandbox/node";
import * as A from "fp-ts/Array";
import * as TE from "fp-ts/TaskEither";
import * as RA from "fp-ts/ReadonlyArray";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
import { flow, pipe } from "fp-ts/function";
import { hrtime } from "process";
import {
RequestRunnerResponse,
TestReport,
TestScriptParams,
} from "../interfaces/response";
import { error, HoppCLIError } from "../types/errors";
import { HoppCLIError, error } from "../types/errors";
import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
import { getDurationInSeconds } from "./getters";
@@ -36,7 +38,7 @@ export const testRunner = (
pipe(
TE.of(testScriptData),
TE.chain(({ testScript, response, envs }) =>
execTestScript(testScript, envs, response)
runTestScript(testScript, envs, response)
)
)
),

View File

@@ -1,26 +1,15 @@
import { Environment } from "@hoppscotch/data"
import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox"
import { runTestScript } from "@hoppscotch/js-sandbox/web"
import * as A from "fp-ts/Array"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { flow, pipe } from "fp-ts/function"
import { cloneDeep } from "lodash-es"
import { Observable, Subject } from "rxjs"
import { filter } from "rxjs/operators"
import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { Environment } from "@hoppscotch/data"
import {
SandboxTestResult,
runTestScript,
TestDescriptor,
} from "@hoppscotch/js-sandbox"
import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es"
import {
getCombinedEnvVariables,
getFinalEnvsFromPreRequest,
} from "./preRequest"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { createRESTNetworkRequestStream } from "./network"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { isJSONContentType } from "./utils/contenttypes"
import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment"
import { Ref } from "vue"
import {
environmentsStore,
getCurrentEnvironment,
@@ -29,9 +18,18 @@ import {
setGlobalEnvVariables,
updateEnvironment,
} from "~/newstore/environments"
import { Ref } from "vue"
import { HoppTab } from "~/services/tab"
import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment"
import { createRESTNetworkRequestStream } from "./network"
import {
getCombinedEnvVariables,
getFinalEnvsFromPreRequest,
} from "./preRequest"
import { HoppRESTDocument } from "./rest/document"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
@@ -89,7 +87,7 @@ export function runRESTRequest$(
const res = getFinalEnvsFromPreRequest(
tab.value.document.request.preRequestScript,
getCombinedEnvVariables()
)().then((envs) => {
).then((envs) => {
if (cancelCalled) return E.left("cancellation" as const)
if (E.isLeft(envs)) {
@@ -125,7 +123,7 @@ export function runRESTRequest$(
body: getTestableBody(res),
headers: res.headers,
}
)()
)
if (E.isRight(runResult)) {
tab.value.document.testResults = translateToSandboxTestResults(

View File

@@ -1,10 +1,13 @@
import { runPreRequestScript } from "@hoppscotch/js-sandbox"
import * as E from "fp-ts/Either"
import { runPreRequestScript } from "@hoppscotch/js-sandbox/web"
import { Environment } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import {
getCurrentEnvironment,
getGlobalVariables,
} from "~/newstore/environments"
import { TestResult } from "@hoppscotch/js-sandbox"
export const getCombinedEnvVariables = () => ({
global: cloneDeep(getGlobalVariables()),
@@ -17,4 +20,5 @@ export const getFinalEnvsFromPreRequest = (
global: Environment["variables"]
selected: Environment["variables"]
}
) => runPreRequestScript(script, envs)
): Promise<E.Either<string, TestResult["envs"]>> =>
runPreRequestScript(script, envs)

View File

@@ -0,0 +1,2 @@
export { default } from "./dist/types/index.d.ts"
export * from "./dist/types/index.d.ts"

View File

@@ -1,6 +1,10 @@
module.exports = {
export default {
preset: "ts-jest",
testEnvironment: "jsdom",
collectCoverage: true,
setupFilesAfterEnv: ["./jest.setup.ts"],
moduleNameMapper: {
"~/(.*)": "<rootDir>/src/$1",
"^lodash-es$": "lodash",
},
}

View File

@@ -0,0 +1,2 @@
export { default } from "./dist/node.d.ts"
export * from "./dist/node.d.ts"

View File

@@ -2,16 +2,27 @@
"name": "@hoppscotch/js-sandbox",
"version": "2.1.0",
"description": "JavaScript sandboxes for running external scripts used by Hoppscotch clients",
"main": "./lib/index.js",
"module": "./lib/index.mjs",
"type": "commonjs",
"type": "module",
"files": [
"dist",
"index.d.ts"
],
"exports": {
".": {
"require": "./lib/index.js",
"default": "./lib/index.mjs"
"types": "./dist/types/index.d.ts"
},
"./web": {
"import": "./dist/web.js",
"require": "./dist/web.cjs",
"types": "./dist/web.d.ts"
},
"./node": {
"import": "./dist/node.js",
"require": "./dist/node.cjs",
"types": "./dist/node.d.ts"
}
},
"types": "./lib/",
"types": "./index.d.ts",
"engines": {
"node": ">=14",
"pnpm": ">=3"
@@ -20,7 +31,7 @@
"lint": "eslint --ext .ts,.js --ignore-path .gitignore .",
"lintfix": "eslint --fix --ext .ts,.js --ignore-path .gitignore .",
"test": "pnpm exec jest",
"build": "pnpm exec tsup",
"build": "vite build && tsc --emitDeclarationOnly",
"clean": "pnpm tsc --build --clean",
"postinstall": "pnpm run build",
"prepublish": "pnpm run build",
@@ -41,10 +52,10 @@
"license": "MIT",
"dependencies": {
"@hoppscotch/data": "workspace:^",
"@types/lodash-es": "^4.17.12",
"fp-ts": "^2.11.10",
"lodash": "^4.17.21",
"quickjs-emscripten": "^0.15.0",
"tsup": "^5.12.5"
"lodash-es": "^4.17.21"
},
"devDependencies": {
"@digitak/esrun": "^3.1.2",
@@ -61,6 +72,7 @@
"jest": "^27.5.1",
"prettier": "^2.8.4",
"ts-jest": "^27.1.4",
"typescript": "^4.6.3"
"typescript": "^4.6.3",
"vite": "^5.0.4"
}
}

View File

@@ -1,10 +1,10 @@
import { execPreRequestScript } from "../preRequest"
import { runPreRequestScript } from "~/pre-request/node-vm"
import "@relmify/jest-fp-ts"
describe("execPreRequestScript", () => {
test("returns the updated envirionment properly", () => {
return expect(
execPreRequestScript(
runPreRequestScript(
`
pw.env.set("bob", "newbob")
`,
@@ -27,7 +27,7 @@ describe("execPreRequestScript", () => {
test("fails if the key is not a string", () => {
return expect(
execPreRequestScript(
runPreRequestScript(
`
pw.env.set(10, "newbob")
`,
@@ -44,7 +44,7 @@ describe("execPreRequestScript", () => {
test("fails if the value is not a string", () => {
return expect(
execPreRequestScript(
runPreRequestScript(
`
pw.env.set("bob", 10)
`,
@@ -61,7 +61,7 @@ describe("execPreRequestScript", () => {
test("fails for invalid syntax", () => {
return expect(
execPreRequestScript(
runPreRequestScript(
`
pw.env.set("bob",
`,
@@ -78,7 +78,7 @@ describe("execPreRequestScript", () => {
test("creates new env variable if doesn't exist", () => {
return expect(
execPreRequestScript(
runPreRequestScript(
`
pw.env.set("foo", "bar")
`,

View File

@@ -1,8 +1,9 @@
import "@relmify/jest-fp-ts"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse, TestResult } from "../../../test-runner"
import "@relmify/jest-fp-ts"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse, TestResult } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -12,7 +13,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, envs: TestResult["envs"]) =>
pipe(
execTestScript(script, envs, fakeResponse),
runTestScript(script, envs, fakeResponse),
TE.map((x) => x.tests)
)

View File

@@ -1,8 +1,9 @@
import "@relmify/jest-fp-ts"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse, TestResult } from "../../../test-runner"
import "@relmify/jest-fp-ts"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse, TestResult } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -12,7 +13,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, envs: TestResult["envs"]) =>
pipe(
execTestScript(script, envs, fakeResponse),
runTestScript(script, envs, fakeResponse),
TE.map((x) => x.tests)
)

View File

@@ -1,6 +1,8 @@
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { execTestScript, TestResponse, TestResult } from "../../../test-runner"
import { pipe } from "fp-ts/function"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse, TestResult } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -10,7 +12,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, envs: TestResult["envs"]) =>
pipe(
execTestScript(script, envs, fakeResponse),
runTestScript(script, envs, fakeResponse),
TE.map((x) => x.tests)
)

View File

@@ -1,6 +1,8 @@
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse, TestResult } from "../../../test-runner"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse, TestResult } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -10,13 +12,13 @@ const fakeResponse: TestResponse = {
const func = (script: string, envs: TestResult["envs"]) =>
pipe(
execTestScript(script, envs, fakeResponse),
runTestScript(script, envs, fakeResponse),
TE.map((x) => x.envs)
)
const funcTest = (script: string, envs: TestResult["envs"]) =>
pipe(
execTestScript(script, envs, fakeResponse),
runTestScript(script, envs, fakeResponse),
TE.map((x) => x.tests)
)

View File

@@ -1,7 +1,9 @@
import "@relmify/jest-fp-ts"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse } from "../../../test-runner"
import "@relmify/jest-fp-ts"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -11,7 +13,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, res: TestResponse) =>
pipe(
execTestScript(script, { global: [], selected: [] }, res),
runTestScript(script, { global: [], selected: [] }, res),
TE.map((x) => x.tests)
)

View File

@@ -1,7 +1,9 @@
import "@relmify/jest-fp-ts"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse } from "../../../test-runner"
import "@relmify/jest-fp-ts"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -11,7 +13,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, res: TestResponse) =>
pipe(
execTestScript(script, { global: [], selected: [] }, res),
runTestScript(script, { global: [], selected: [] }, res),
TE.map((x) => x.tests)
)

View File

@@ -1,6 +1,8 @@
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse } from "../../../test-runner"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -10,7 +12,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, res: TestResponse) =>
pipe(
execTestScript(script, { global: [], selected: [] }, res),
runTestScript(script, { global: [], selected: [] }, res),
TE.map((x) => x.tests)
)

View File

@@ -1,6 +1,8 @@
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse } from "../../../test-runner"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -10,7 +12,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, res: TestResponse) =>
pipe(
execTestScript(script, { global: [], selected: [] }, res),
runTestScript(script, { global: [], selected: [] }, res),
TE.map((x) => x.tests)
)

View File

@@ -1,6 +1,8 @@
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse } from "../../../test-runner"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -10,7 +12,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, res: TestResponse) =>
pipe(
execTestScript(script, { global: [], selected: [] }, res),
runTestScript(script, { global: [], selected: [] }, res),
TE.map((x) => x.tests)
)

View File

@@ -1,6 +1,8 @@
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { execTestScript, TestResponse } from "../../test-runner"
import { runTestScript } from "~/test-runner/node-vm"
import { TestResponse } from "~/types"
const fakeResponse: TestResponse = {
status: 200,
@@ -10,7 +12,7 @@ const fakeResponse: TestResponse = {
const func = (script: string, res: TestResponse) =>
pipe(
execTestScript(script, { global: [], selected: [] }, res),
runTestScript(script, { global: [], selected: [] }, res),
TE.map((x) => x.tests)
)

View File

@@ -1,40 +1,15 @@
import { match } from "fp-ts/lib/Either"
import { pipe } from "fp-ts/lib/function"
import * as QuickJS from "quickjs-emscripten"
import { marshalObjectToVM } from "../utils"
import { preventCyclicObjects } from "~/utils"
let vm: QuickJS.QuickJSVm
beforeAll(async () => {
const qjs = await QuickJS.getQuickJS()
vm = qjs.createVm()
})
afterAll(() => {
vm.dispose()
})
describe("marshalObjectToVM", () => {
test("successfully marshals simple object into the vm", () => {
describe("preventCyclicObjects", () => {
test("succeeds with a simple object", () => {
const testObj = {
a: 1,
}
const objVMHandle: QuickJS.QuickJSHandle | null = pipe(
marshalObjectToVM(vm, testObj),
match(
() => null,
(result) => result
)
)
expect(objVMHandle).not.toBeNull()
expect(vm.dump(objVMHandle!)).toEqual(testObj)
objVMHandle!.dispose()
expect(preventCyclicObjects(testObj)).toBeRight()
})
test("fails marshalling cyclic object into vm", () => {
test("fails with a cyclic object", () => {
const testObj = {
a: 1,
b: null as any,
@@ -42,14 +17,6 @@ describe("marshalObjectToVM", () => {
testObj.b = testObj
const objVMHandle: QuickJS.QuickJSHandle | null = pipe(
marshalObjectToVM(vm, testObj),
match(
() => null,
(result) => result
)
)
expect(objVMHandle).toBeNull()
expect(preventCyclicObjects(testObj)).toBeLeft()
})
})

View File

@@ -1,43 +0,0 @@
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { execPreRequestScript } from "./preRequest"
import {
execTestScript,
TestResponse as _TestResponse,
TestDescriptor as _TestDescriptor,
TestResult,
} from "./test-runner"
export * from "./test-runner"
export type TestResponse = _TestResponse
export type TestDescriptor = _TestDescriptor
export type SandboxTestResult = TestResult & { tests: TestDescriptor }
/**
* Executes a given test script on the test-runner sandbox
* @param testScript The string of the script to run
* @returns A TaskEither with an error message or a TestDescriptor with the final status
*/
export const runTestScript = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
) =>
pipe(
execTestScript(testScript, envs, response),
TE.chain((results) =>
TE.right(<SandboxTestResult>{
envs: results.envs,
tests: results.tests[0],
})
) // execTestScript returns an array of descriptors with a single element (extract that)
)
/**
* Executes a given pre-request script on the sandbox
* @param preRequestScript The script to run
* @param env The environment variables active
* @returns A TaskEither with an error message or an array of the final environments with the all the script values applied
*/
export const runPreRequestScript = execPreRequestScript

View File

@@ -0,0 +1,2 @@
export * from "./pre-request/node-vm"
export * from "./test-runner/node-vm"

View File

@@ -0,0 +1,36 @@
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/lib/TaskEither"
import { createContext, runInContext } from "vm"
import { TestResult } from "~/types"
import { getPreRequestScriptMethods } from "~/utils"
export const runPreRequestScript = (
preRequestScript: string,
envs: TestResult["envs"]
): TE.TaskEither<string, TestResult["envs"]> =>
pipe(
TE.tryCatch(
async () => {
return createContext()
},
(reason) => `Context initialization failed: ${reason}`
),
TE.chain((context) =>
TE.tryCatch(
() =>
new Promise((resolve) => {
const { pw, updatedEnvs } = getPreRequestScriptMethods(envs)
// Expose pw to the context
context.pw = pw
// Run the pre-request script in the provided context
runInContext(preRequestScript, context)
resolve(updatedEnvs)
}),
(reason) => `Script execution failed: ${reason}`
)
)
)

View File

@@ -0,0 +1,24 @@
import * as E from "fp-ts/Either"
import { TestResult } from "~/types"
import Worker from "./worker?worker&inline"
export const runPreRequestScript = (
preRequestScript: string,
envs: TestResult["envs"]
): Promise<E.Either<string, TestResult["envs"]>> =>
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,
})
})

View File

@@ -0,0 +1,33 @@
import * as TE from "fp-ts/TaskEither"
import { TestResult } from "~/types"
import { getPreRequestScriptMethods } from "~/utils"
const executeScriptInContext = (
preRequestScript: string,
envs: TestResult["envs"]
): TE.TaskEither<string, TestResult["envs"]> => {
try {
const { pw, updatedEnvs } = getPreRequestScriptMethods(envs)
// Create a function from the pre request script using the `Function` constructor
const executeScript = new Function("pw", preRequestScript)
// Execute the script
executeScript(pw)
return TE.right(updatedEnvs)
} catch (error) {
return TE.left(`Script execution failed: ${(error as Error).message}`)
}
}
// Listen for messages from the main thread
self.addEventListener("message", async (event) => {
const { preRequestScript, envs } = event.data
const results = await executeScriptInContext(preRequestScript, envs)()
// Post the result back to the main thread
self.postMessage({ results })
})

View File

@@ -1,166 +0,0 @@
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/lib/TaskEither"
import * as qjs from "quickjs-emscripten"
import cloneDeep from "lodash/clone"
import { Environment, parseTemplateStringE } from "@hoppscotch/data"
import { getEnv, setEnv } from "./utils"
type Envs = {
global: Environment["variables"]
selected: Environment["variables"]
}
export const execPreRequestScript = (
preRequestScript: string,
envs: Envs
): TE.TaskEither<string, Envs> =>
pipe(
TE.tryCatch(
async () => await qjs.getQuickJS(),
(reason) => `QuickJS initialization failed: ${reason}`
),
TE.chain((QuickJS) => {
let currentEnvs = cloneDeep(envs)
const vm = QuickJS.createVm()
const pwHandle = vm.newObject()
// Environment management APIs
// TODO: Unified Implementation
const envHandle = vm.newObject()
const envGetHandle = vm.newFunction("get", (keyHandle) => {
const key: unknown = vm.dump(keyHandle)
if (typeof key !== "string") {
return {
error: vm.newString("Expected key to be a string"),
}
}
const result = pipe(
getEnv(key, currentEnvs),
O.match(
() => vm.undefined,
({ value }) => vm.newString(value)
)
)
return {
value: result,
}
})
const envGetResolveHandle = vm.newFunction("getResolve", (keyHandle) => {
const key: unknown = vm.dump(keyHandle)
if (typeof key !== "string") {
return {
error: vm.newString("Expected key to be a string"),
}
}
const result = pipe(
getEnv(key, currentEnvs),
E.fromOption(() => "INVALID_KEY" as const),
E.map(({ value }) =>
pipe(
parseTemplateStringE(value, [...envs.selected, ...envs.global]),
// If the recursive resolution failed, return the unresolved value
E.getOrElse(() => value)
)
),
// Create a new VM String
// NOTE: Do not shorten this to map(vm.newString) apparently it breaks it
E.map((x) => vm.newString(x)),
E.getOrElse(() => vm.undefined)
)
return {
value: result,
}
})
const envSetHandle = vm.newFunction("set", (keyHandle, valueHandle) => {
const key: unknown = vm.dump(keyHandle)
const value: unknown = vm.dump(valueHandle)
if (typeof key !== "string") {
return {
error: vm.newString("Expected key to be a string"),
}
}
if (typeof value !== "string") {
return {
error: vm.newString("Expected value to be a string"),
}
}
currentEnvs = setEnv(key, value, currentEnvs)
return {
value: vm.undefined,
}
})
const envResolveHandle = vm.newFunction("resolve", (valueHandle) => {
const value: unknown = vm.dump(valueHandle)
if (typeof value !== "string") {
return {
error: vm.newString("Expected value to be a string"),
}
}
const result = pipe(
parseTemplateStringE(value, [
...currentEnvs.selected,
...currentEnvs.global,
]),
E.getOrElse(() => value)
)
return {
value: vm.newString(result),
}
})
vm.setProp(envHandle, "resolve", envResolveHandle)
envResolveHandle.dispose()
vm.setProp(envHandle, "set", envSetHandle)
envSetHandle.dispose()
vm.setProp(envHandle, "getResolve", envGetResolveHandle)
envGetResolveHandle.dispose()
vm.setProp(envHandle, "get", envGetHandle)
envGetHandle.dispose()
vm.setProp(pwHandle, "env", envHandle)
envHandle.dispose()
vm.setProp(vm.global, "pw", pwHandle)
pwHandle.dispose()
const evalRes = vm.evalCode(preRequestScript)
if (evalRes.error) {
const errorData = vm.dump(evalRes.error)
evalRes.error.dispose()
return TE.left(errorData)
}
vm.dispose()
return TE.right(currentEnvs)
})
)

View File

@@ -1,610 +0,0 @@
import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import * as qjs from "quickjs-emscripten"
import { Environment, parseTemplateStringE } from "@hoppscotch/data"
import cloneDeep from "lodash/cloneDeep"
import { getEnv, marshalObjectToVM, setEnv } from "./utils"
/**
* The response object structure exposed to the test script
*/
export type TestResponse = {
/** Status Code of the response */
status: number
/** List of headers returned */
headers: { key: string; value: string }[]
/**
* Body of the response, this will be the JSON object if it is a JSON content type, else body string
*/
body: string | object
}
/**
* The result of an expectation statement
*/
type ExpectResult = { status: "pass" | "fail" | "error"; message: string } // The expectation failed (fail) or errored (error)
/**
* An object defining the result of the execution of a
* test block
*/
export type TestDescriptor = {
/**
* The name of the test block
*/
descriptor: string
/**
* Expectation results of the test block
*/
expectResults: ExpectResult[]
/**
* Children test blocks (test blocks inside the test block)
*/
children: TestDescriptor[]
}
/**
* Defines the result of a test script execution
*/
export type TestResult = {
tests: TestDescriptor[]
envs: {
global: Environment["variables"]
selected: Environment["variables"]
}
}
/**
* Creates an Expectation object for use inside the sandbox
* @param vm The QuickJS sandbox VM instance
* @param expectVal The expecting value of the expectation
* @param negated Whether the expectation is negated (negative)
* @param currTestStack The current state of the test execution stack
* @returns Handle to the expectation object in VM
*/
function createExpectation(
vm: qjs.QuickJSVm,
expectVal: any,
negated: boolean,
currTestStack: TestDescriptor[]
): qjs.QuickJSHandle {
const resultHandle = vm.newObject()
const toBeFnHandle = vm.newFunction("toBe", (expectedValHandle) => {
const expectedVal = vm.dump(expectedValHandle)
let assertion = expectVal === expectedVal
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be '${expectedVal}'`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be '${expectedVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel2xxHandle = vm.newFunction("toBeLevel2xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 200 && expectVal <= 299
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 200-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 200-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 200-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel3xxHandle = vm.newFunction("toBeLevel3xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 300 && expectVal <= 399
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 300-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 300-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 300-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel4xxHandle = vm.newFunction("toBeLevel4xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 400 && expectVal <= 499
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 400-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 400-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 400-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel5xxHandle = vm.newFunction("toBeLevel5xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 500 && expectVal <= 599
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 500-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be 500-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 500-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeTypeHandle = vm.newFunction("toBeType", (expectedValHandle) => {
const expectedType = vm.dump(expectedValHandle)
// Check if the expectation param is a valid type name string, else error
if (
[
"string",
"boolean",
"number",
"object",
"undefined",
"bigint",
"symbol",
"function",
].includes(expectedType)
) {
let assertion = typeof expectVal === expectedType
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be type '${expectedType}'`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${
negated ? " not" : ""
} be type '${expectedType}'`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`,
})
}
return { value: vm.undefined }
})
const toHaveLengthHandle = vm.newFunction(
"toHaveLength",
(expectedValHandle) => {
const expectedLength = vm.dump(expectedValHandle)
if (!(Array.isArray(expectVal) || typeof expectVal === "string")) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected toHaveLength to be called for an array or string`,
})
return { value: vm.undefined }
}
// Check if the parameter is a number, else error
if (typeof expectedLength === "number" && !Number.isNaN(expectedLength)) {
let assertion = (expectVal as any[]).length === expectedLength
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected the array to${
negated ? " not" : ""
} be of length '${expectedLength}'`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected the array to${
negated ? " not" : ""
} be of length '${expectedLength}'`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Argument for toHaveLength should be a number`,
})
}
return { value: vm.undefined }
}
)
const toIncludeHandle = vm.newFunction("toInclude", (needleHandle) => {
const expectedVal = vm.dump(needleHandle)
if (!(Array.isArray(expectVal) || typeof expectVal === "string")) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected toInclude to be called for an array or string`,
})
return { value: vm.undefined }
}
if (expectedVal === null) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Argument for toInclude should not be null`,
})
return { value: vm.undefined }
}
if (expectedVal === undefined) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Argument for toInclude should not be undefined`,
})
return { value: vm.undefined }
}
let assertion = expectVal.includes(expectedVal)
if (negated) assertion = !assertion
const expectValPretty = JSON.stringify(expectVal)
const expectedValPretty = JSON.stringify(expectedVal)
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected ${expectValPretty} to${
negated ? " not" : ""
} include ${expectedValPretty}`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected ${expectValPretty} to${
negated ? " not" : ""
} include ${expectedValPretty}`,
})
}
return { value: vm.undefined }
})
vm.setProp(resultHandle, "toBe", toBeFnHandle)
vm.setProp(resultHandle, "toBeLevel2xx", toBeLevel2xxHandle)
vm.setProp(resultHandle, "toBeLevel3xx", toBeLevel3xxHandle)
vm.setProp(resultHandle, "toBeLevel4xx", toBeLevel4xxHandle)
vm.setProp(resultHandle, "toBeLevel5xx", toBeLevel5xxHandle)
vm.setProp(resultHandle, "toBeType", toBeTypeHandle)
vm.setProp(resultHandle, "toHaveLength", toHaveLengthHandle)
vm.setProp(resultHandle, "toInclude", toIncludeHandle)
vm.defineProp(resultHandle, "not", {
get: () => {
return createExpectation(vm, expectVal, !negated, currTestStack)
},
})
toBeFnHandle.dispose()
toBeLevel2xxHandle.dispose()
toBeLevel3xxHandle.dispose()
toBeLevel4xxHandle.dispose()
toBeLevel5xxHandle.dispose()
toBeTypeHandle.dispose()
toHaveLengthHandle.dispose()
toIncludeHandle.dispose()
return resultHandle
}
export const execTestScript = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
): TE.TaskEither<string, TestResult> =>
pipe(
TE.tryCatch(
async () => await qjs.getQuickJS(),
(reason) => `QuickJS initialization failed: ${reason}`
),
TE.chain(
// TODO: Make this more functional ?
(QuickJS) => {
let currentEnvs = cloneDeep(envs)
const vm = QuickJS.createVm()
const pwHandle = vm.newObject()
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
const testFuncHandle = vm.newFunction(
"test",
(descriptorHandle, testFuncHandle) => {
const descriptor = vm.getString(descriptorHandle)
testRunStack.push({
descriptor,
expectResults: [],
children: [],
})
const result = vm.unwrapResult(
vm.callFunction(testFuncHandle, vm.null)
)
result.dispose()
const child = testRunStack.pop() as TestDescriptor
testRunStack[testRunStack.length - 1].children.push(child)
}
)
const expectFnHandle = vm.newFunction("expect", (expectValueHandle) => {
const expectVal = vm.dump(expectValueHandle)
return {
value: createExpectation(vm, expectVal, false, testRunStack),
}
})
// Marshal response object
const responseObjHandle = marshalObjectToVM(vm, response)
if (E.isLeft(responseObjHandle))
return TE.left(
`Response marshalling failed: ${responseObjHandle.left}`
)
vm.setProp(pwHandle, "response", responseObjHandle.right)
responseObjHandle.right.dispose()
vm.setProp(pwHandle, "expect", expectFnHandle)
expectFnHandle.dispose()
vm.setProp(pwHandle, "test", testFuncHandle)
testFuncHandle.dispose()
// Environment management APIs
// TODO: Unified Implementation
const envHandle = vm.newObject()
const envGetHandle = vm.newFunction("get", (keyHandle) => {
const key: unknown = vm.dump(keyHandle)
if (typeof key !== "string") {
return {
error: vm.newString("Expected key to be a string"),
}
}
const result = pipe(
getEnv(key, currentEnvs),
O.match(
() => vm.undefined,
({ value }) => vm.newString(value)
)
)
return {
value: result,
}
})
const envGetResolveHandle = vm.newFunction(
"getResolve",
(keyHandle) => {
const key: unknown = vm.dump(keyHandle)
if (typeof key !== "string") {
return {
error: vm.newString("Expected key to be a string"),
}
}
const result = pipe(
getEnv(key, currentEnvs),
E.fromOption(() => "INVALID_KEY" as const),
E.map(({ value }) =>
pipe(
parseTemplateStringE(value, [
...envs.selected,
...envs.global,
]),
// If the recursive resolution failed, return the unresolved value
E.getOrElse(() => value)
)
),
// Create a new VM String
// NOTE: Do not shorten this to map(vm.newString) apparently it breaks it
E.map((x) => vm.newString(x)),
E.getOrElse(() => vm.undefined)
)
return {
value: result,
}
}
)
const envSetHandle = vm.newFunction("set", (keyHandle, valueHandle) => {
const key: unknown = vm.dump(keyHandle)
const value: unknown = vm.dump(valueHandle)
if (typeof key !== "string") {
return {
error: vm.newString("Expected key to be a string"),
}
}
if (typeof value !== "string") {
return {
error: vm.newString("Expected value to be a string"),
}
}
currentEnvs = setEnv(key, value, currentEnvs)
return {
value: vm.undefined,
}
})
const envResolveHandle = vm.newFunction("resolve", (valueHandle) => {
const value: unknown = vm.dump(valueHandle)
if (typeof value !== "string") {
return {
error: vm.newString("Expected value to be a string"),
}
}
const result = pipe(
parseTemplateStringE(value, [
...currentEnvs.selected,
...currentEnvs.global,
]),
E.getOrElse(() => value)
)
return {
value: vm.newString(result),
}
})
vm.setProp(envHandle, "resolve", envResolveHandle)
envResolveHandle.dispose()
vm.setProp(envHandle, "set", envSetHandle)
envSetHandle.dispose()
vm.setProp(envHandle, "getResolve", envGetResolveHandle)
envGetResolveHandle.dispose()
vm.setProp(envHandle, "get", envGetHandle)
envGetHandle.dispose()
vm.setProp(pwHandle, "env", envHandle)
envHandle.dispose()
vm.setProp(vm.global, "pw", pwHandle)
pwHandle.dispose()
const evalRes = vm.evalCode(testScript)
if (evalRes.error) {
const errorData = vm.dump(evalRes.error)
evalRes.error.dispose()
return TE.left(`Script evaluation failed: ${errorData}`)
}
vm.dispose()
return TE.right({
tests: testRunStack,
envs: currentEnvs,
})
}
)
)

View File

@@ -0,0 +1,55 @@
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { createContext, runInContext } from "vm"
import { TestResponse, TestResult } from "~/types"
import { getTestRunnerScriptMethods, preventCyclicObjects } from "~/utils"
export const runTestScript = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
): TE.TaskEither<string, TestResult> =>
pipe(
TE.tryCatch(
async () => {
return createContext()
},
(reason) => `Context initialization failed: ${reason}`
),
TE.chain((context) =>
TE.tryCatch(
() => executeScriptInContext(testScript, envs, response, context),
(reason) => `Script execution failed: ${reason}`
)
)
)
const executeScriptInContext = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse,
context: any
): Promise<TestResult> => {
return new Promise((resolve, reject) => {
// Parse response object
const responseObjHandle = preventCyclicObjects(response)
if (E.isLeft(responseObjHandle)) {
return reject(`Response parsing failed: ${responseObjHandle.left}`)
}
const { pw, testRunStack, updatedEnvs } = getTestRunnerScriptMethods(envs)
// Expose pw to the context
context.pw = { ...pw, response: responseObjHandle.right }
// Run the test script in the provided context
runInContext(testScript, context)
resolve({
tests: testRunStack,
envs: updatedEnvs,
})
})
}

View File

@@ -0,0 +1,27 @@
import * as E from "fp-ts/Either"
import { SandboxTestResult, TestResponse, TestResult } from "~/types"
import Worker from "./worker?worker&inline"
export const runTestScript = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
): Promise<E.Either<string, SandboxTestResult>> => {
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,
})
})
}

View File

@@ -0,0 +1,43 @@
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { SandboxTestResult, TestResponse, TestResult } from "~/types"
import { getTestRunnerScriptMethods, preventCyclicObjects } from "~/utils"
const executeScriptInContext = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
): TE.TaskEither<string, SandboxTestResult> => {
try {
const responseObjHandle = preventCyclicObjects(response)
if (E.isLeft(responseObjHandle)) {
return TE.left(`Response marshalling failed: ${responseObjHandle.left}`)
}
const { pw, testRunStack, updatedEnvs } = getTestRunnerScriptMethods(envs)
// Create a function from the test script using the `Function` constructor
const executeScript = new Function("pw", testScript)
// Execute the script
executeScript({ ...pw, response: responseObjHandle.right })
return TE.right(<SandboxTestResult>{
tests: testRunStack[0],
envs: updatedEnvs,
})
} catch (error) {
return TE.left(`Script execution failed: ${(error as Error).message}`)
}
}
// Listen for messages from the main thread
self.addEventListener("message", async (event) => {
const { testScript, envs, response } = event.data
const results = await executeScriptInContext(testScript, envs, response)()
// Post the result back to the main thread
self.postMessage({ results })
})

View File

@@ -0,0 +1,61 @@
import { Environment } from "@hoppscotch/data"
/**
* The response object structure exposed to the test script
*/
export type TestResponse = {
/** Status Code of the response */
status: number
/** List of headers returned */
headers: { key: string; value: string }[]
/**
* Body of the response, this will be the JSON object if it is a JSON content type, else body string
*/
body: string | object
}
/**
* The result of an expectation statement
*/
export type ExpectResult = {
status: "pass" | "fail" | "error"
message: string
} // The expectation failed (fail) or errored (error)
/**
* An object defining the result of the execution of a
* test block
*/
export type TestDescriptor = {
/**
* The name of the test block
*/
descriptor: string
/**
* Expectation results of the test block
*/
expectResults: ExpectResult[]
/**
* Children test blocks (test blocks inside the test block)
*/
children: TestDescriptor[]
}
/**
* Defines the result of a test script execution
*/
export type TestResult = {
tests: TestDescriptor[]
envs: {
global: Environment["variables"]
selected: Environment["variables"]
}
}
export type GlobalEnvItem = TestResult["envs"]["global"][number]
export type SelectedEnvItem = TestResult["envs"]["selected"][number]
export type SandboxTestResult = TestResult & { tests: TestDescriptor }

View File

@@ -1,89 +1,439 @@
import * as O from "fp-ts/Option"
import { parseTemplateStringE } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import * as QuickJS from "quickjs-emscripten"
import { TestResult } from "./test-runner"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/lib/function"
import { cloneDeep } from "lodash-es"
export function marshalObjectToVM(
vm: QuickJS.QuickJSVm,
obj: object
): E.Either<string, QuickJS.QuickJSHandle> {
let jsonString
import {
GlobalEnvItem,
SelectedEnvItem,
TestDescriptor,
TestResult,
} from "./types"
try {
jsonString = JSON.stringify(obj)
} catch (e) {
return E.left("Marshaling stringification failed")
}
const vmStringHandle = vm.newString(jsonString)
const jsonHandle = vm.getProp(vm.global, "JSON")
const parseFuncHandle = vm.getProp(jsonHandle, "parse")
const parseResultHandle = vm.callFunction(
parseFuncHandle,
vm.undefined,
vmStringHandle
)
if (parseResultHandle.error) {
parseResultHandle.error.dispose()
return E.left("Marshaling failed")
}
const resultHandle = vm.unwrapResult(parseResultHandle)
vmStringHandle.dispose()
parseFuncHandle.dispose()
jsonHandle.dispose()
return E.right(resultHandle)
}
export function getEnv(envName: string, envs: TestResult["envs"]) {
const getEnv = (envName: string, envs: TestResult["envs"]) => {
return O.fromNullable(
envs.selected.find((x) => x.key === envName) ??
envs.global.find((x) => x.key === envName)
envs.selected.find((x: SelectedEnvItem) => x.key === envName) ??
envs.global.find((x: GlobalEnvItem) => x.key === envName)
)
}
export function setEnv(
// Compiles shared scripting API methods for use in both pre and post request scripts
const getSharedMethods = (envs: TestResult["envs"]) => {
let updatedEnvs = envs
const envGetFn = (key: any) => {
if (typeof key !== "string") {
throw new Error("Expected key to be a string")
}
const result = pipe(
getEnv(key, updatedEnvs),
O.match(
() => undefined,
({ value }) => String(value)
)
)
return result
}
const envGetResolveFn = (key: any) => {
if (typeof key !== "string") {
throw new Error("Expected key to be a string")
}
const result = pipe(
getEnv(key, updatedEnvs),
E.fromOption(() => "INVALID_KEY" as const),
E.map(({ value }) =>
pipe(
parseTemplateStringE(value, [
...updatedEnvs.selected,
...updatedEnvs.global,
]),
// If the recursive resolution failed, return the unresolved value
E.getOrElse(() => value)
)
),
E.map((x) => String(x)),
E.getOrElseW(() => undefined)
)
return result
}
const envSetFn = (key: any, value: any) => {
if (typeof key !== "string") {
throw new Error("Expected key to be a string")
}
if (typeof value !== "string") {
throw new Error("Expected value to be a string")
}
updatedEnvs = setEnv(key, value, updatedEnvs)
return undefined
}
const envResolveFn = (value: any) => {
if (typeof value !== "string") {
throw new Error("Expected value to be a string")
}
const result = pipe(
parseTemplateStringE(value, [
...updatedEnvs.selected,
...updatedEnvs.global,
]),
E.getOrElse(() => value)
)
return String(result)
}
return {
methods: {
env: {
get: envGetFn,
getResolve: envGetResolveFn,
set: envSetFn,
resolve: envResolveFn,
},
},
updatedEnvs,
}
}
const setEnv = (
envName: string,
envValue: string,
envs: TestResult["envs"]
): TestResult["envs"] {
const indexInSelected = envs.selected.findIndex((x) => x.key === envName)
): TestResult["envs"] => {
const { global, selected } = envs
const indexInSelected = selected.findIndex(
(x: SelectedEnvItem) => x.key === envName
)
// Found the match in selected
if (indexInSelected >= 0) {
envs.selected[indexInSelected].value = envValue
selected[indexInSelected].value = envValue
return {
global: envs.global,
selected: envs.selected,
global,
selected,
}
}
const indexInGlobal = envs.global.findIndex((x) => x.key == envName)
const indexInGlobal = global.findIndex((x: GlobalEnvItem) => x.key == envName)
// Found a match in globals
if (indexInGlobal >= 0) {
envs.global[indexInGlobal].value = envValue
global[indexInGlobal].value = envValue
return {
global: envs.global,
selected: envs.selected,
global,
selected,
}
}
// Didn't find in both places, create a new variable in selected
envs.selected.push({
selected.push({
key: envName,
value: envValue,
})
return {
global: envs.global,
selected: envs.selected,
global,
selected,
}
}
export function preventCyclicObjects(
obj: Record<string, any>
): E.Left<string> | E.Right<Record<string, any>> {
let jsonString
try {
jsonString = JSON.stringify(obj)
} catch (e) {
return E.left("Stringification failed")
}
try {
const parsedJson = JSON.parse(jsonString)
return E.right(parsedJson)
} catch (err) {
return E.left("Parsing failed")
}
}
/**
* Creates an Expectation object for use inside the sandbox
* @param expectVal The expecting value of the expectation
* @param negated Whether the expectation is negated (negative)
* @param currTestStack The current state of the test execution stack
* @returns Object with the expectation methods
*/
export const createExpectation = (
expectVal: any,
negated: boolean,
currTestStack: TestDescriptor[]
) => {
const result: Record<string, unknown> = {}
const toBeFn = (expectedVal: any) => {
let assertion = expectVal === expectedVal
if (negated) {
assertion = !assertion
}
const status = assertion ? "pass" : "fail"
const message = `Expected '${expectVal}' to${
negated ? " not" : ""
} be '${expectedVal}'`
currTestStack[currTestStack.length - 1].expectResults.push({
status,
message,
})
return undefined
}
const toBeLevelXxx = (
level: string,
rangeStart: number,
rangeEnd: number
) => {
const parsedExpectVal = parseInt(expectVal)
if (!Number.isNaN(parsedExpectVal)) {
let assertion =
parsedExpectVal >= rangeStart && parsedExpectVal <= rangeEnd
if (negated) {
assertion = !assertion
}
const status = assertion ? "pass" : "fail"
const message = `Expected '${parsedExpectVal}' to${
negated ? " not" : ""
} be ${level}-level status`
currTestStack[currTestStack.length - 1].expectResults.push({
status,
message,
})
} else {
const message = `Expected ${level}-level status but could not parse value '${expectVal}'`
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
}
return undefined
}
const toBeLevel2xxFn = () => toBeLevelXxx("200", 200, 299)
const toBeLevel3xxFn = () => toBeLevelXxx("300", 300, 399)
const toBeLevel4xxFn = () => toBeLevelXxx("400", 400, 499)
const toBeLevel5xxFn = () => toBeLevelXxx("500", 500, 599)
const toBeTypeFn = (expectedType: any) => {
if (
[
"string",
"boolean",
"number",
"object",
"undefined",
"bigint",
"symbol",
"function",
].includes(expectedType)
) {
let assertion = typeof expectVal === expectedType
if (negated) {
assertion = !assertion
}
const status = assertion ? "pass" : "fail"
const message = `Expected '${expectVal}' to${
negated ? " not" : ""
} be type '${expectedType}'`
currTestStack[currTestStack.length - 1].expectResults.push({
status,
message,
})
} else {
const message =
'Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"'
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
}
return undefined
}
const toHaveLengthFn = (expectedLength: any) => {
if (!(Array.isArray(expectVal) || typeof expectVal === "string")) {
const message =
"Expected toHaveLength to be called for an array or string"
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
return undefined
}
if (typeof expectedLength === "number" && !Number.isNaN(expectedLength)) {
let assertion = expectVal.length === expectedLength
if (negated) {
assertion = !assertion
}
const status = assertion ? "pass" : "fail"
const message = `Expected the array to${
negated ? " not" : ""
} be of length '${expectedLength}'`
currTestStack[currTestStack.length - 1].expectResults.push({
status,
message,
})
} else {
const message = "Argument for toHaveLength should be a number"
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
}
return undefined
}
const toIncludeFn = (needle: any) => {
if (!(Array.isArray(expectVal) || typeof expectVal === "string")) {
const message = "Expected toInclude to be called for an array or string"
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
return undefined
}
if (needle === null) {
const message = "Argument for toInclude should not be null"
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
return undefined
}
if (needle === undefined) {
const message = "Argument for toInclude should not be undefined"
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message,
})
return undefined
}
let assertion = expectVal.includes(needle)
if (negated) {
assertion = !assertion
}
const expectValPretty = JSON.stringify(expectVal)
const needlePretty = JSON.stringify(needle)
const status = assertion ? "pass" : "fail"
const message = `Expected ${expectValPretty} to${
negated ? " not" : ""
} include ${needlePretty}`
currTestStack[currTestStack.length - 1].expectResults.push({
status,
message,
})
return undefined
}
result.toBe = toBeFn
result.toBeLevel2xx = toBeLevel2xxFn
result.toBeLevel3xx = toBeLevel3xxFn
result.toBeLevel4xx = toBeLevel4xxFn
result.toBeLevel5xx = toBeLevel5xxFn
result.toBeType = toBeTypeFn
result.toHaveLength = toHaveLengthFn
result.toInclude = toIncludeFn
Object.defineProperties(result, {
not: {
get: () => createExpectation(expectVal, !negated, currTestStack),
},
})
return result
}
/**
* Compiles methods for use under the `pw` namespace for pre request scripts
* @param envs The current state of the environment variables
* @returns Object with methods in the `pw` namespace
*/
export const getPreRequestScriptMethods = (envs: TestResult["envs"]) => {
const { methods, updatedEnvs } = getSharedMethods(cloneDeep(envs))
return { pw: methods, updatedEnvs }
}
/**
* Compiles methods for use under the `pw` namespace for post request scripts
* @param envs The current state of the environment variables
* @returns Object with methods in the `pw` namespace and test run stack
*/
export const getTestRunnerScriptMethods = (envs: TestResult["envs"]) => {
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
const testFn = (descriptor: string, testFunc: () => void) => {
testRunStack.push({
descriptor,
expectResults: [],
children: [],
})
testFunc()
const child = testRunStack.pop() as TestDescriptor
testRunStack[testRunStack.length - 1].children.push(child)
}
const expectFn = (expectVal: any) =>
createExpectation(expectVal, false, testRunStack)
const { methods, updatedEnvs } = getSharedMethods(cloneDeep(envs))
const pw = {
...methods,
expect: expectFn,
test: testFn,
}
return { pw, testRunStack, updatedEnvs }
}

View File

@@ -0,0 +1,2 @@
export * from "./pre-request/web-worker"
export * from "./test-runner/web-worker"

View File

@@ -1,19 +1,17 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"skipLibCheck": true,
"lib": ["ESNext", "ESNext.AsyncIterable", "DOM"],
"esModuleInterop": true,
"strict": true,
"paths": {
"~/*": ["./src/*"],
"@/*": ["./src/*"]
"~/*": ["./src/*"]
},
"types": ["@types/node", "@types/jest", "@relmify/jest-fp-ts"],
"outDir": "./lib/",
"rootDir": "./src/",
"types": ["@types/node", "@types/jest", "@relmify/jest-fp-ts", "vite/client"],
"outDir": "./dist/",
"declaration": true,
"declarationMap": true,
"sourceMap": true

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
outDir: "./lib/",
format: ["esm", "cjs"],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
})

View File

@@ -0,0 +1,25 @@
import { resolve } from "path"
import { defineConfig } from "vite"
export default defineConfig({
build: {
outDir: "./dist",
emptyOutDir: true,
lib: {
entry: {
web: "./src/web.ts",
node: "./src/node.ts",
},
name: "js-sandbox",
formats: ["es", "cjs"],
},
rollupOptions: {
external: ["vm"],
},
},
resolve: {
alias: {
"~": resolve(__dirname, "./src"),
},
},
})

View File

@@ -0,0 +1,2 @@
export { default } from "./dist/web.d.ts"
export * from "./dist/web.d.ts"