611 lines
18 KiB
TypeScript
611 lines
18 KiB
TypeScript
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,
|
|
})
|
|
}
|
|
)
|
|
)
|