import { parseTemplateStringE } from "@hoppscotch/data" import * as E from "fp-ts/Either" import * as O from "fp-ts/Option" import { pipe } from "fp-ts/lib/function" import { cloneDeep } from "lodash-es" import { GlobalEnvItem, SelectedEnvItem, TestDescriptor, TestResult, } from "./types" const getEnv = (envName: string, envs: TestResult["envs"]) => { return O.fromNullable( envs.selected.find((x: SelectedEnvItem) => x.key === envName) ?? envs.global.find((x: GlobalEnvItem) => x.key === envName) ) } const findEnvIndex = ( envName: string, envList: SelectedEnvItem[] | GlobalEnvItem[] ): number => { return envList.findIndex( (envItem: SelectedEnvItem) => envItem.key === envName ) } const setEnv = ( envName: string, envValue: string, envs: TestResult["envs"] ): TestResult["envs"] => { const { global, selected } = envs const indexInSelected = findEnvIndex(envName, selected) const indexInGlobal = findEnvIndex(envName, global) if (indexInSelected >= 0) { const selectedEnv = selected[indexInSelected] if ("value" in selectedEnv) { selectedEnv.value = envValue } } else if (indexInGlobal >= 0) { if ("value" in global[indexInGlobal]) { // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(global[indexInGlobal] as { value: string }).value = envValue } } else { selected.push({ key: envName, value: envValue, secret: false, }) } return { global, selected, } } const unsetEnv = ( envName: string, envs: TestResult["envs"] ): TestResult["envs"] => { const { global, selected } = envs const indexInSelected = findEnvIndex(envName, selected) const indexInGlobal = findEnvIndex(envName, global) if (indexInSelected >= 0) { selected.splice(indexInSelected, 1) } else if (indexInGlobal >= 0) { global.splice(indexInGlobal, 1) } return { global, selected, } } // Compiles shared scripting API methods for use in both pre and post request scripts const getSharedMethods = (envs: TestResult["envs"]) => { let updatedEnvs = envs const envGetFn = (key: any) => { if (typeof key !== "string") { throw new Error("Expected key to be a string") } const result = pipe( getEnv(key, updatedEnvs), O.fold( () => undefined, (env) => String(env.value) ) ) return result } const envGetResolveFn = (key: any) => { if (typeof key !== "string") { throw new Error("Expected key to be a string") } const result = pipe( getEnv(key, updatedEnvs), E.fromOption(() => "INVALID_KEY" as const), E.map((e) => pipe( parseTemplateStringE(e.value, [ ...updatedEnvs.selected, ...updatedEnvs.global, ]), // If the recursive resolution failed, return the unresolved value E.getOrElse(() => e.value) ) ), E.map((x) => String(x)), E.getOrElseW(() => undefined) ) return result } const envSetFn = (key: any, value: any) => { if (typeof key !== "string") { throw new Error("Expected key to be a string") } if (typeof value !== "string") { throw new Error("Expected value to be a string") } updatedEnvs = setEnv(key, value, updatedEnvs) return undefined } const envUnsetFn = (key: any) => { if (typeof key !== "string") { throw new Error("Expected key to be a string") } updatedEnvs = unsetEnv(key, updatedEnvs) return undefined } const envResolveFn = (value: any) => { if (typeof value !== "string") { throw new Error("Expected value to be a string") } const result = pipe( parseTemplateStringE(value, [ ...updatedEnvs.selected, ...updatedEnvs.global, ]), E.getOrElse(() => value) ) return String(result) } return { methods: { env: { get: envGetFn, getResolve: envGetResolveFn, set: envSetFn, unset: envUnsetFn, resolve: envResolveFn, }, }, updatedEnvs, } } export function preventCyclicObjects( obj: Record ): E.Left | E.Right> { let jsonString try { jsonString = JSON.stringify(obj) } catch (e) { return E.left("Stringification failed") } try { const parsedJson = JSON.parse(jsonString) return E.right(parsedJson) } catch (err) { return E.left("Parsing failed") } } /** * Creates an Expectation object for use inside the sandbox * @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 Object with the expectation methods */ export const createExpectation = ( expectVal: any, negated: boolean, currTestStack: TestDescriptor[] ) => { const result: Record = {} const toBeFn = (expectedVal: any) => { let assertion = expectVal === expectedVal if (negated) { assertion = !assertion } const status = assertion ? "pass" : "fail" const message = `Expected '${expectVal}' to${ negated ? " not" : "" } be '${expectedVal}'` currTestStack[currTestStack.length - 1].expectResults.push({ status, message, }) return undefined } const toBeLevelXxx = ( level: string, rangeStart: number, rangeEnd: number ) => { const parsedExpectVal = parseInt(expectVal) if (!Number.isNaN(parsedExpectVal)) { let assertion = parsedExpectVal >= rangeStart && parsedExpectVal <= rangeEnd if (negated) { assertion = !assertion } const status = assertion ? "pass" : "fail" const message = `Expected '${parsedExpectVal}' to${ negated ? " not" : "" } be ${level}-level status` currTestStack[currTestStack.length - 1].expectResults.push({ status, message, }) } else { const message = `Expected ${level}-level status but could not parse value '${expectVal}'` currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) } return undefined } const toBeLevel2xxFn = () => toBeLevelXxx("200", 200, 299) const toBeLevel3xxFn = () => toBeLevelXxx("300", 300, 399) const toBeLevel4xxFn = () => toBeLevelXxx("400", 400, 499) const toBeLevel5xxFn = () => toBeLevelXxx("500", 500, 599) const toBeTypeFn = (expectedType: any) => { if ( [ "string", "boolean", "number", "object", "undefined", "bigint", "symbol", "function", ].includes(expectedType) ) { let assertion = typeof expectVal === expectedType if (negated) { assertion = !assertion } const status = assertion ? "pass" : "fail" const message = `Expected '${expectVal}' to${ negated ? " not" : "" } be type '${expectedType}'` currTestStack[currTestStack.length - 1].expectResults.push({ status, message, }) } else { const message = 'Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"' currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) } return undefined } const toHaveLengthFn = (expectedLength: any) => { if (!(Array.isArray(expectVal) || typeof expectVal === "string")) { const message = "Expected toHaveLength to be called for an array or string" currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) return undefined } if (typeof expectedLength === "number" && !Number.isNaN(expectedLength)) { let assertion = expectVal.length === expectedLength if (negated) { assertion = !assertion } const status = assertion ? "pass" : "fail" const message = `Expected the array to${ negated ? " not" : "" } be of length '${expectedLength}'` currTestStack[currTestStack.length - 1].expectResults.push({ status, message, }) } else { const message = "Argument for toHaveLength should be a number" currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) } return undefined } const toIncludeFn = (needle: any) => { if (!(Array.isArray(expectVal) || typeof expectVal === "string")) { const message = "Expected toInclude to be called for an array or string" currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) return undefined } if (needle === null) { const message = "Argument for toInclude should not be null" currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) return undefined } if (needle === undefined) { const message = "Argument for toInclude should not be undefined" currTestStack[currTestStack.length - 1].expectResults.push({ status: "error", message, }) return undefined } let assertion = expectVal.includes(needle) if (negated) { assertion = !assertion } const expectValPretty = JSON.stringify(expectVal) const needlePretty = JSON.stringify(needle) const status = assertion ? "pass" : "fail" const message = `Expected ${expectValPretty} to${ negated ? " not" : "" } include ${needlePretty}` currTestStack[currTestStack.length - 1].expectResults.push({ status, message, }) return undefined } result.toBe = toBeFn result.toBeLevel2xx = toBeLevel2xxFn result.toBeLevel3xx = toBeLevel3xxFn result.toBeLevel4xx = toBeLevel4xxFn result.toBeLevel5xx = toBeLevel5xxFn result.toBeType = toBeTypeFn result.toHaveLength = toHaveLengthFn result.toInclude = toIncludeFn Object.defineProperties(result, { not: { get: () => createExpectation(expectVal, !negated, currTestStack), }, }) return result } /** * Compiles methods for use under the `pw` namespace for pre request scripts * @param envs The current state of the environment variables * @returns Object with methods in the `pw` namespace */ export const getPreRequestScriptMethods = (envs: TestResult["envs"]) => { const { methods, updatedEnvs } = getSharedMethods(cloneDeep(envs)) return { pw: methods, updatedEnvs } } /** * Compiles methods for use under the `pw` namespace for post request scripts * @param envs The current state of the environment variables * @returns Object with methods in the `pw` namespace and test run stack */ export const getTestRunnerScriptMethods = (envs: TestResult["envs"]) => { const testRunStack: TestDescriptor[] = [ { descriptor: "root", expectResults: [], children: [] }, ] const testFn = (descriptor: string, testFunc: () => void) => { testRunStack.push({ descriptor, expectResults: [], children: [], }) testFunc() const child = testRunStack.pop() as TestDescriptor testRunStack[testRunStack.length - 1].children.push(child) } const expectFn = (expectVal: any) => createExpectation(expectVal, false, testRunStack) const { methods, updatedEnvs } = getSharedMethods(cloneDeep(envs)) const pw = { ...methods, expect: expectFn, test: testFn, } return { pw, testRunStack, updatedEnvs } }