Files
hoppscotch/packages/hoppscotch-js-sandbox/src/node/test-runner.ts
2024-04-19 21:08:46 +05:30

218 lines
6.9 KiB
TypeScript

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)
})
})
}