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