import { isLeft } from "fp-ts/lib/Either" import { pipe } from "fp-ts/lib/function" import { TaskEither, tryCatch, chain, right, left } from "fp-ts/lib/TaskEither" import * as qjs from "quickjs-emscripten" import { marshalObjectToVM } 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[] } /** * 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 } } ) 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.defineProp(resultHandle, "not", { get: () => { return createExpectation(vm, expectVal, !negated, currTestStack) }, }) toBeFnHandle.dispose() toBeLevel2xxHandle.dispose() toBeLevel3xxHandle.dispose() toBeLevel4xxHandle.dispose() toBeLevel5xxHandle.dispose() toBeTypeHandle.dispose() toHaveLengthHandle.dispose() return resultHandle } export const execTestScript = ( testScript: string, response: TestResponse ): TaskEither => pipe( tryCatch( async () => await qjs.getQuickJS(), (reason) => `QuickJS initialization failed: ${reason}` ), chain( // TODO: Make this more functional ? (QuickJS) => { 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 (isLeft(responseObjHandle)) return 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() 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 left(`Script evaluation failed: ${errorData}`) } vm.dispose() return right(testRunStack) } ) )