chore: migrate Node.js implementation for js-sandbox to isolated-vm (#3973)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
217
packages/hoppscotch-js-sandbox/src/node/test-runner.ts
Normal file
217
packages/hoppscotch-js-sandbox/src/node/test-runner.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { createRequire } from "module"
|
||||
|
||||
import type ivmT from "isolated-vm"
|
||||
|
||||
import { TestResponse, TestResult } from "~/types"
|
||||
import {
|
||||
getTestRunnerScriptMethods,
|
||||
preventCyclicObjects,
|
||||
} from "~/shared-utils"
|
||||
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
|
||||
): TE.TaskEither<string, TestResult> =>
|
||||
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}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
const executeScriptInContext = (
|
||||
testScript: string,
|
||||
envs: TestResult["envs"],
|
||||
response: TestResponse,
|
||||
isolate: ivmT.Isolate,
|
||||
context: ivmT.Context
|
||||
): 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 jail = context.global
|
||||
|
||||
const { pw, testRunStack, updatedEnvs } = getTestRunnerScriptMethods(envs)
|
||||
|
||||
const serializedAPIMethods = getSerializedAPIMethods({
|
||||
...pw,
|
||||
response: responseObjHandle.right,
|
||||
})
|
||||
jail.setSync("serializedAPIMethods", serializedAPIMethods, { copy: true })
|
||||
|
||||
jail.setSync("atob", atob)
|
||||
jail.setSync("btoa", btoa)
|
||||
|
||||
jail.setSync("ivm", ivm)
|
||||
|
||||
// Methods in the isolate context can't be invoked straightaway
|
||||
const finalScript = `
|
||||
const pw = new Proxy(serializedAPIMethods, {
|
||||
get: (pwObj, pwObjProp) => {
|
||||
// pw.expect(), pw.env, etc.
|
||||
const topLevelEntry = pwObj[pwObjProp]
|
||||
|
||||
// If the entry exists and is a function
|
||||
// pw.expect(), pw.test(), etc.
|
||||
if (topLevelEntry && topLevelEntry.typeof === "function") {
|
||||
// pw.test() just involves invoking the function via "applySync()"
|
||||
if (pwObjProp === "test") {
|
||||
return (...args) => topLevelEntry.applySync(null, args)
|
||||
}
|
||||
|
||||
// pw.expect() returns an object with matcher methods
|
||||
return (...args) => {
|
||||
// Invoke "pw.expect()" and get access to the object with matcher methods
|
||||
const expectFnResult = topLevelEntry.applySync(
|
||||
null,
|
||||
args.map((expectVal) => {
|
||||
if (typeof expectVal === "object") {
|
||||
if (expectVal === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only arrays and objects stringified here should be parsed from the "pw.expect()" method definition
|
||||
// The usecase is that any JSON string supplied should be preserved
|
||||
// An extra "isStringifiedWithinIsolate" prop is added to indicate it has to be parsed
|
||||
|
||||
if (Array.isArray(expectVal)) {
|
||||
return JSON.stringify({
|
||||
arr: expectVal,
|
||||
isStringifiedWithinIsolate: true,
|
||||
})
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
...expectVal,
|
||||
isStringifiedWithinIsolate: true,
|
||||
})
|
||||
}
|
||||
|
||||
return expectVal
|
||||
})
|
||||
)
|
||||
|
||||
// Matcher methods that can be chained with "pw.expect()"
|
||||
// pw.expect().toBe(), etc
|
||||
if (expectFnResult.typeof === "object") {
|
||||
// Access the getter that points to the negated matcher methods via "{ accessors: true }"
|
||||
const matcherMethods = {
|
||||
not: expectFnResult.getSync("not", { accessors: true }),
|
||||
}
|
||||
|
||||
// Serialize matcher methods for use in the isolate context
|
||||
const matcherMethodNames = [
|
||||
"toBe",
|
||||
"toBeLevel2xx",
|
||||
"toBeLevel3xx",
|
||||
"toBeLevel4xx",
|
||||
"toBeLevel5xx",
|
||||
"toBeType",
|
||||
"toHaveLength",
|
||||
"toInclude",
|
||||
]
|
||||
matcherMethodNames.forEach((methodName) => {
|
||||
matcherMethods[methodName] = expectFnResult.getSync(methodName)
|
||||
})
|
||||
|
||||
return new Proxy(matcherMethods, {
|
||||
get: (matcherMethodTarget, matcherMethodProp) => {
|
||||
// pw.expect().not.toBe(), etc
|
||||
const matcherMethodEntry = matcherMethodTarget[matcherMethodProp]
|
||||
|
||||
if (matcherMethodProp === "not") {
|
||||
return new Proxy(matcherMethodEntry, {
|
||||
get: (negatedObjTarget, negatedObjprop) => {
|
||||
// Return the negated matcher method defn that is invoked from the test script
|
||||
const negatedMatcherMethodDefn = negatedObjTarget.getSync(negatedObjprop)
|
||||
return negatedMatcherMethodDefn
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Return the matcher method defn that is invoked from the test script
|
||||
return matcherMethodEntry
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "pw.env" set of API methods
|
||||
if (typeof topLevelEntry === "object" && pwObjProp !== "response") {
|
||||
return new Proxy(topLevelEntry, {
|
||||
get: (subTarget, subProp) => {
|
||||
const subLevelProperty = subTarget[subProp]
|
||||
if (
|
||||
subLevelProperty &&
|
||||
subLevelProperty.typeof === "function"
|
||||
) {
|
||||
return (...args) => subLevelProperty.applySync(null, args)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return topLevelEntry
|
||||
},
|
||||
})
|
||||
|
||||
${testScript}
|
||||
`
|
||||
|
||||
// Create a script and compile it
|
||||
const script = isolate.compileScript(finalScript)
|
||||
|
||||
// Run the test script in the provided context
|
||||
script
|
||||
.then((script) => script.run(context))
|
||||
.then(() => {
|
||||
resolve({
|
||||
tests: testRunStack,
|
||||
envs: updatedEnvs,
|
||||
})
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user