Files
hoppscotch/packages/hoppscotch-js-sandbox/src/test-runner.ts
Ace Nassri 9b5fec7b32 feat: add toInclude() function (#2234)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-04-05 15:57:14 +05:30

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