218 lines
6.9 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
}
|