Feature: hopp-cli in TypeScript (#2074)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com> Co-authored-by: liyasthomas <liyascthomas@gmail.com> Co-authored-by: Gita Alekhya Paul <gitaalekhyapaul@gmail.com>
This commit is contained in:
155
packages/hoppscotch-cli/src/utils/checks.ts
Normal file
155
packages/hoppscotch-cli/src/utils/checks.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import fs from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { pipe } from "fp-ts/function";
|
||||
import {
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
isHoppRESTRequest,
|
||||
} from "@hoppscotch/data";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as S from "fp-ts/string";
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import { error, HoppCLIError, HoppErrnoException } from "../types/errors";
|
||||
import { CommanderError } from "commander";
|
||||
|
||||
/**
|
||||
* Determines whether an object has a property with given name.
|
||||
* @param target Object to be checked for given property.
|
||||
* @param prop Property to be checked in target object.
|
||||
* @returns True, if property exists in target object; False, otherwise.
|
||||
*/
|
||||
export const hasProperty = <P extends PropertyKey>(
|
||||
target: object,
|
||||
prop: P
|
||||
): target is Record<P, unknown> => prop in target;
|
||||
|
||||
/**
|
||||
* Typeguard to check valid Hoppscotch REST Collection.
|
||||
* @param param The object to be checked.
|
||||
* @returns True, if unknown parameter is valid Hoppscotch REST Collection;
|
||||
* False, otherwise.
|
||||
*/
|
||||
export const isRESTCollection = (
|
||||
param: unknown
|
||||
): param is HoppCollection<HoppRESTRequest> => {
|
||||
if (!!param && typeof param === "object") {
|
||||
if (!hasProperty(param, "v") || typeof param.v !== "number") {
|
||||
return false;
|
||||
}
|
||||
if (!hasProperty(param, "name") || typeof param.name !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (hasProperty(param, "id") && typeof param.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!hasProperty(param, "requests") || !Array.isArray(param.requests)) {
|
||||
return false;
|
||||
} else {
|
||||
// Checks each requests array to be valid HoppRESTRequest.
|
||||
const checkRequests = A.every(isHoppRESTRequest)(param.requests);
|
||||
if (!checkRequests) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!hasProperty(param, "folders") || !Array.isArray(param.folders)) {
|
||||
return false;
|
||||
} else {
|
||||
// Checks each folder to be valid REST collection.
|
||||
const checkFolders = A.every(isRESTCollection)(param.folders);
|
||||
if (!checkFolders) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given file path exists and is of JSON type.
|
||||
* @param path The input file path to check.
|
||||
* @returns Absolute path for valid file path OR HoppCLIError in case of error.
|
||||
*/
|
||||
export const checkFilePath = (
|
||||
path: string
|
||||
): TE.TaskEither<HoppCLIError, string> =>
|
||||
pipe(
|
||||
path,
|
||||
|
||||
/**
|
||||
* Check the path type and returns string if passes else HoppCLIError.
|
||||
*/
|
||||
TE.fromPredicate(S.isString, () => error({ code: "NO_FILE_PATH" })),
|
||||
|
||||
/**
|
||||
* Trying to access given file path.
|
||||
* If successfully accessed, we return the path from predicate step.
|
||||
* Else return HoppCLIError with code FILE_NOT_FOUND.
|
||||
*/
|
||||
TE.chainFirstW(
|
||||
TE.tryCatchK(
|
||||
() => pipe(path, join, fs.access),
|
||||
() => error({ code: "FILE_NOT_FOUND", path: path })
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* On successfully accessing given file path, we map file path to
|
||||
* absolute path and return abs file path if file is JSON type.
|
||||
*/
|
||||
TE.map(join),
|
||||
TE.chainW(
|
||||
TE.fromPredicate(S.endsWith(".json"), (absPath) =>
|
||||
error({ code: "FILE_NOT_JSON", path: absPath })
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if given error data is of type HoppCLIError, based on existence
|
||||
* of code property.
|
||||
* @param error Error data to check.
|
||||
* @returns True, if unknown error validates to be HoppCLIError;
|
||||
* False, otherwise.
|
||||
*/
|
||||
export const isHoppCLIError = (error: unknown): error is HoppCLIError => {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
hasProperty(error, "code") &&
|
||||
typeof error.code === "string"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if given error data is of type HoppErrnoException, based on existence
|
||||
* of name property.
|
||||
* @param error Error data to check.
|
||||
* @returns True, if unknown error validates to be HoppErrnoException;
|
||||
* False, otherwise.
|
||||
*/
|
||||
export const isHoppErrnoException = (
|
||||
error: unknown
|
||||
): error is HoppErrnoException => {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
hasProperty(error, "name") &&
|
||||
typeof error.name === "string"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether given unknown error is instance of commander-error and
|
||||
* has zero exit code (which we consider as safe error).
|
||||
* @param error Error data to check.
|
||||
* @returns True, if error data validates to be safe-commander-error;
|
||||
* False, otherwise.
|
||||
*/
|
||||
export const isSafeCommanderError = (
|
||||
error: unknown
|
||||
): error is CommanderError => {
|
||||
return error instanceof CommanderError && error.exitCode === 0;
|
||||
};
|
||||
129
packages/hoppscotch-cli/src/utils/collections.ts
Normal file
129
packages/hoppscotch-cli/src/utils/collections.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import * as T from "fp-ts/Task";
|
||||
import * as A from "fp-ts/Array";
|
||||
import { pipe } from "fp-ts/function";
|
||||
import { bold } from "chalk";
|
||||
import { log } from "console";
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||
import { HoppEnvs, CollectionStack, RequestReport } from "../types/request";
|
||||
import { preProcessRequest, processRequest } from "./request";
|
||||
import { exceptionColors } from "./getters";
|
||||
import { TestReport } from "../interfaces/response";
|
||||
import {
|
||||
printErrorsReport,
|
||||
printFailedTestsReport,
|
||||
printTestsMetrics,
|
||||
} from "./display";
|
||||
const { WARN, FAIL } = exceptionColors;
|
||||
|
||||
/**
|
||||
* Processes each requests within collections to prints details of subsequent requests,
|
||||
* tests and to display complete errors-report, failed-tests-report and test-metrics.
|
||||
* @param collections Array of hopp-collection with hopp-requests to be processed.
|
||||
* @returns List of report for each processed request.
|
||||
*/
|
||||
export const collectionsRunner =
|
||||
(collections: HoppCollection<HoppRESTRequest>[]): T.Task<RequestReport[]> =>
|
||||
async () => {
|
||||
const envs: HoppEnvs = { global: [], selected: [] };
|
||||
const requestsReport: RequestReport[] = [];
|
||||
const collectionStack: CollectionStack[] = getCollectionStack(collections);
|
||||
|
||||
while (collectionStack.length) {
|
||||
// Pop out top-most collection from stack to be processed.
|
||||
const { collection, path } = <CollectionStack>collectionStack.pop();
|
||||
|
||||
// Processing each request in collection
|
||||
for (const request of collection.requests) {
|
||||
const _request = preProcessRequest(request);
|
||||
const requestPath = `${path}/${_request.name}`;
|
||||
|
||||
// Request processing initiated message.
|
||||
log(WARN(`\nRunning: ${bold(requestPath)}`));
|
||||
|
||||
// Processing current request.
|
||||
const result = await processRequest(_request, envs, requestPath)();
|
||||
|
||||
// Updating global & selected envs with new envs from processed-request output.
|
||||
const { global, selected } = result.envs;
|
||||
envs.global = global;
|
||||
envs.selected = selected;
|
||||
|
||||
// Storing current request's report.
|
||||
const requestReport = result.report;
|
||||
requestsReport.push(requestReport);
|
||||
}
|
||||
|
||||
// Pushing remaining folders realted collection to stack.
|
||||
for (const folder of collection.folders) {
|
||||
collectionStack.push({
|
||||
path: `${path}/${folder.name}`,
|
||||
collection: folder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return requestsReport;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms collections to generate collection-stack which describes each collection's
|
||||
* path within collection & the collection itself.
|
||||
* @param collections Hopp-collection objects to be mapped to collection-stack type.
|
||||
* @returns Mapped collections to collection-stack.
|
||||
*/
|
||||
const getCollectionStack = (
|
||||
collections: HoppCollection<HoppRESTRequest>[]
|
||||
): CollectionStack[] =>
|
||||
pipe(
|
||||
collections,
|
||||
A.map(
|
||||
(collection) => <CollectionStack>{ collection, path: collection.name }
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Prints collection-runner-report using test-metrics data in table format.
|
||||
* @param requestsReport Provides data for each request-report which includes
|
||||
* failed-tests-report, errors
|
||||
* @returns True, if collection runner executed without any errors or failed test-cases.
|
||||
* False, if errors occured or test-cases failed.
|
||||
*/
|
||||
export const collectionsRunnerResult = (
|
||||
requestsReport: RequestReport[]
|
||||
): boolean => {
|
||||
const testsReport: TestReport[] = [];
|
||||
let finalResult = true;
|
||||
|
||||
// Printing requests-report details of failed-tests and errors
|
||||
for (const requestReport of requestsReport) {
|
||||
const { path, tests, errors, result } = requestReport;
|
||||
|
||||
finalResult = finalResult && result;
|
||||
|
||||
printFailedTestsReport(path, tests);
|
||||
|
||||
printErrorsReport(path, errors);
|
||||
|
||||
testsReport.push.apply(testsReport, tests);
|
||||
}
|
||||
|
||||
printTestsMetrics(testsReport);
|
||||
|
||||
return finalResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exiting hopp cli process with appropriate exit code depending on
|
||||
* collections-runner result.
|
||||
* If result is true, we exit the cli process with code 0.
|
||||
* Else, exit with code 1.
|
||||
* @param result Boolean defining the collections-runner result.
|
||||
*/
|
||||
export const collectionsRunnerExit = (result: boolean) => {
|
||||
if (!result) {
|
||||
const EXIT_MSG = FAIL(`\nExited with code 1`);
|
||||
process.stdout.write(EXIT_MSG);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
7
packages/hoppscotch-cli/src/utils/constants.ts
Normal file
7
packages/hoppscotch-cli/src/utils/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ResponseErrorPair } from "../interfaces/response";
|
||||
|
||||
export const responseErrors: ResponseErrorPair = {
|
||||
501: "REQUEST NOT SUPPORTED",
|
||||
408: "NETWORK TIMEOUT",
|
||||
400: "BAD REQUEST",
|
||||
} as const;
|
||||
145
packages/hoppscotch-cli/src/utils/display.ts
Normal file
145
packages/hoppscotch-cli/src/utils/display.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { bold } from "chalk";
|
||||
import { groupEnd, group, log } from "console";
|
||||
import { handleError } from "../handlers/error";
|
||||
import { RequestConfig } from "../interfaces/request";
|
||||
import { RequestRunnerResponse, TestReport } from "../interfaces/response";
|
||||
import { HoppCLIError } from "../types/errors";
|
||||
import { exceptionColors, getColorStatusCode } from "./getters";
|
||||
import {
|
||||
getFailedExpectedResults,
|
||||
getFailedTestsReport,
|
||||
getTestMetrics,
|
||||
} from "./test";
|
||||
const { FAIL, SUCCESS, BG_INFO } = exceptionColors;
|
||||
|
||||
/**
|
||||
* Prints test-suites in pretty-way describing each test-suites failed/passed
|
||||
* status.
|
||||
* @param testsReport Providing details of each test-suites with tests-report.
|
||||
*/
|
||||
export const printTestSuitesReport = (testsReport: TestReport[]) => {
|
||||
group();
|
||||
for (const testReport of testsReport) {
|
||||
const { failing, descriptor } = testReport;
|
||||
|
||||
if (failing > 0) {
|
||||
log(`${FAIL("✖")} ${descriptor}`);
|
||||
} else {
|
||||
log(`${SUCCESS("✔")} ${descriptor}`);
|
||||
}
|
||||
}
|
||||
groupEnd();
|
||||
};
|
||||
|
||||
/**
|
||||
* Prints total number of test-cases and test-suites passed/failed.
|
||||
* @param testsReport Provides testSuites and testCases metrics.
|
||||
*/
|
||||
export const printTestsMetrics = (testsReport: TestReport[]) => {
|
||||
const { testSuites, tests } = getTestMetrics(testsReport);
|
||||
|
||||
const failedTestCasesOut = FAIL(`${tests.failing} failing`);
|
||||
const passedTestCasesOut = SUCCESS(`${tests.passing} passing`);
|
||||
const testCasesOut = `Test Cases: ${failedTestCasesOut} ${passedTestCasesOut}\n`;
|
||||
|
||||
const failedTestSuitesOut = FAIL(`${testSuites.failing} failing`);
|
||||
const passedTestSuitesOut = SUCCESS(`${testSuites.passing} passing`);
|
||||
const testSuitesOut = `Test Suites: ${failedTestSuitesOut} ${passedTestSuitesOut}\n`;
|
||||
|
||||
const message = `\n${testCasesOut}${testSuitesOut}`;
|
||||
process.stdout.write(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Prints details of each reported error for a request with error code.
|
||||
* @param path Request's path in collection for which errors occured.
|
||||
* @param errorsReport List of errors reported.
|
||||
*/
|
||||
export const printErrorsReport = (
|
||||
path: string,
|
||||
errorsReport: HoppCLIError[]
|
||||
) => {
|
||||
if (errorsReport.length > 0) {
|
||||
const REPORTED_ERRORS_TITLE = FAIL(`\n${bold(path)} reported errors:`);
|
||||
|
||||
group(REPORTED_ERRORS_TITLE);
|
||||
for (const errorReport of errorsReport) {
|
||||
handleError(errorReport);
|
||||
}
|
||||
groupEnd();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prints details of each failed tests for given request's path.
|
||||
* @param path Request's path in collection for which tests-failed.
|
||||
* @param testsReport Overall tests-report including failed-tests-report.
|
||||
*/
|
||||
export const printFailedTestsReport = (
|
||||
path: string,
|
||||
testsReport: TestReport[]
|
||||
) => {
|
||||
const failedTestsReport = getFailedTestsReport(testsReport);
|
||||
|
||||
// Only printing test-reports with failing test-cases.
|
||||
if (failedTestsReport.length > 0) {
|
||||
const FAILED_TESTS_PATH = FAIL(`\n${bold(path)} failed tests:`);
|
||||
group(FAILED_TESTS_PATH);
|
||||
|
||||
for (const failedTestReport of failedTestsReport) {
|
||||
const { descriptor, expectResults } = failedTestReport;
|
||||
const failedExpectResults = getFailedExpectedResults(expectResults);
|
||||
|
||||
// Only printing failed expected-results.
|
||||
if (failedExpectResults.length > 0) {
|
||||
group("⦁", descriptor);
|
||||
|
||||
for (const failedExpectResult of failedExpectResults) {
|
||||
log(FAIL("-"), failedExpectResult.message);
|
||||
}
|
||||
|
||||
groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
groupEnd();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides methods for printing request-runner's state messages.
|
||||
*/
|
||||
export const printRequestRunner = {
|
||||
// Request-runner starting message.
|
||||
start: (requestConfig: RequestConfig) => {
|
||||
const METHOD = BG_INFO(` ${requestConfig.method} `);
|
||||
const ENDPOINT = requestConfig.url;
|
||||
|
||||
process.stdout.write(`${METHOD} ${ENDPOINT}`);
|
||||
},
|
||||
|
||||
// Prints response's status, when request-runner executes successfully.
|
||||
success: (requestResponse: RequestRunnerResponse) => {
|
||||
const { status, statusText } = requestResponse;
|
||||
const statusMsg = getColorStatusCode(status, statusText);
|
||||
|
||||
process.stdout.write(` ${statusMsg}\n`);
|
||||
},
|
||||
|
||||
// Prints error message, when request-runner fails to execute.
|
||||
fail: () => log(FAIL(" ERROR\n⚠ Error running request.")),
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides methods for printing test-runner's state messages.
|
||||
*/
|
||||
export const printTestRunner = {
|
||||
fail: () => log(FAIL("⚠ Error running test-script.")),
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides methods for printing pre-request-runner's state messages.
|
||||
*/
|
||||
export const printPreRequestRunner = {
|
||||
fail: () => log(FAIL("⚠ Error running pre-request-script.")),
|
||||
};
|
||||
37
packages/hoppscotch-cli/src/utils/functions/array.ts
Normal file
37
packages/hoppscotch-cli/src/utils/functions/array.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { clone } from "lodash";
|
||||
|
||||
/**
|
||||
* Sorts the array based on the sort func.
|
||||
* NOTE: Creates a new array, if you don't need ref
|
||||
* to original array, use `arrayUnsafeSort` for better perf
|
||||
* @param sortFunc Sort function to sort against
|
||||
*/
|
||||
export const arraySort =
|
||||
<T>(sortFunc: (a: T, b: T) => number) =>
|
||||
(arr: T[]) => {
|
||||
const newArr = clone(arr);
|
||||
|
||||
newArr.sort(sortFunc);
|
||||
|
||||
return newArr;
|
||||
};
|
||||
|
||||
/**
|
||||
* Equivalent to `Array.prototype.flatMap`.
|
||||
* @param mapFunc The map function.
|
||||
* @returns Array formed by applying given mapFunc.
|
||||
*/
|
||||
export const arrayFlatMap =
|
||||
<T, U>(mapFunc: (value: T, index: number, arr: T[]) => U[]) =>
|
||||
(arr: T[]) =>
|
||||
arr.flatMap(mapFunc);
|
||||
|
||||
export const tupleToRecord = <
|
||||
KeyType extends string | number | symbol,
|
||||
ValueType
|
||||
>(
|
||||
tuples: [KeyType, ValueType][]
|
||||
): Record<KeyType, ValueType> =>
|
||||
tuples.length > 0
|
||||
? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val })))
|
||||
: {};
|
||||
113
packages/hoppscotch-cli/src/utils/getters.ts
Normal file
113
packages/hoppscotch-cli/src/utils/getters.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
HoppRESTHeader,
|
||||
Environment,
|
||||
parseTemplateStringE,
|
||||
HoppRESTParam,
|
||||
} from "@hoppscotch/data";
|
||||
import chalk from "chalk";
|
||||
import { pipe } from "fp-ts/function";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as E from "fp-ts/Either";
|
||||
import * as S from "fp-ts/string";
|
||||
import * as O from "fp-ts/Option";
|
||||
import { error } from "../types/errors";
|
||||
|
||||
/**
|
||||
* Generates template string (status + statusText) with specific color unicodes
|
||||
* based on type of status.
|
||||
* @param status Status code of a HTTP response.
|
||||
* @param statusText Status text of a HTTP response.
|
||||
* @returns Template string with related color unicodes.
|
||||
*/
|
||||
export const getColorStatusCode = (
|
||||
status: number | string,
|
||||
statusText: string
|
||||
): string => {
|
||||
const statusCode = `${status == 0 ? "Error" : status} : ${statusText}`;
|
||||
|
||||
if (status.toString().startsWith("2")) {
|
||||
return chalk.greenBright(statusCode);
|
||||
} else if (status.toString().startsWith("3")) {
|
||||
return chalk.yellowBright(statusCode);
|
||||
}
|
||||
|
||||
return chalk.redBright(statusCode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces all template-string with their effective ENV values to generate effective
|
||||
* request headers/parameters meta-data.
|
||||
* @param metaData Headers/parameters on which ENVs will be applied.
|
||||
* @param environment Provides ENV variables for parsing template-string.
|
||||
* @returns Active, non-empty-key, parsed headers/parameters pairs.
|
||||
*/
|
||||
export const getEffectiveFinalMetaData = (
|
||||
metaData: HoppRESTHeader[] | HoppRESTParam[],
|
||||
environment: Environment
|
||||
) =>
|
||||
pipe(
|
||||
metaData,
|
||||
|
||||
/**
|
||||
* Selecting only non-empty and active pairs.
|
||||
*/
|
||||
A.filter(({ key, active }) => !S.isEmpty(key) && active),
|
||||
A.map(({ key, value }) => ({
|
||||
active: true,
|
||||
key: parseTemplateStringE(key, environment.variables),
|
||||
value: parseTemplateStringE(value, environment.variables),
|
||||
})),
|
||||
E.fromPredicate(
|
||||
/**
|
||||
* Check if every key-value is right either. Else return HoppCLIError with
|
||||
* appropriate reason.
|
||||
*/
|
||||
A.every(({ key, value }) => E.isRight(key) && E.isRight(value)),
|
||||
(reason) => error({ code: "PARSING_ERROR", data: reason })
|
||||
),
|
||||
E.map(
|
||||
/**
|
||||
* Filtering and mapping only right-eithers for each key-value as [string, string].
|
||||
*/
|
||||
A.filterMap(({ key, value }) =>
|
||||
E.isRight(key) && E.isRight(value)
|
||||
? O.some({ active: true, key: key.right, value: value.right })
|
||||
: O.none
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Reduces array of HoppRESTParam or HoppRESTHeader to unique key-value
|
||||
* pair.
|
||||
* @param metaData Array of meta-data to reduce.
|
||||
* @returns Object with unique key-value pair.
|
||||
*/
|
||||
export const getMetaDataPairs = (
|
||||
metaData: HoppRESTParam[] | HoppRESTHeader[]
|
||||
) =>
|
||||
pipe(
|
||||
metaData,
|
||||
|
||||
// Excluding non-active & empty key request meta-data.
|
||||
A.filter(({ active, key }) => active && !S.isEmpty(key)),
|
||||
|
||||
// Reducing array of request-meta-data to key-value pair object.
|
||||
A.reduce(<Record<string, string>>{}, (target, { key, value }) =>
|
||||
Object.assign(target, { [`${key}`]: value })
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Object providing aliases for chalk color properties based on exceptions.
|
||||
*/
|
||||
export const exceptionColors = {
|
||||
WARN: chalk.yellow,
|
||||
INFO: chalk.blue,
|
||||
FAIL: chalk.red,
|
||||
SUCCESS: chalk.green,
|
||||
BG_WARN: chalk.bgYellow,
|
||||
BG_FAIL: chalk.bgRed,
|
||||
BG_INFO: chalk.bgBlue,
|
||||
BG_SUCCESS: chalk.bgGreen,
|
||||
};
|
||||
80
packages/hoppscotch-cli/src/utils/mutators.ts
Normal file
80
packages/hoppscotch-cli/src/utils/mutators.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from "fs/promises";
|
||||
import * as E from "fp-ts/Either";
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as J from "fp-ts/Json";
|
||||
import { pipe } from "fp-ts/function";
|
||||
import { FormDataEntry } from "../types/request";
|
||||
import { error, HoppCLIError } from "../types/errors";
|
||||
import { isRESTCollection, isHoppErrnoException } from "./checks";
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||
|
||||
/**
|
||||
* Parses array of FormDataEntry to FormData.
|
||||
* @param values Array of FormDataEntry.
|
||||
* @returns FormData with key-value pair from FormDataEntry.
|
||||
*/
|
||||
export const toFormData = (values: FormDataEntry[]) => {
|
||||
const formData = new FormData();
|
||||
|
||||
values.forEach(({ key, value }) => formData.append(key, value));
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses provided error message to maintain hopp-error messages.
|
||||
* @param e Custom error data.
|
||||
* @returns Parsed error message without extra spaces.
|
||||
*/
|
||||
export const parseErrorMessage = (e: unknown) => {
|
||||
let msg: string;
|
||||
if (isHoppErrnoException(e)) {
|
||||
msg = e.message.replace(e.code! + ":", "").replace("error:", "");
|
||||
} else if (typeof e === "string") {
|
||||
msg = e;
|
||||
} else {
|
||||
msg = JSON.stringify(e);
|
||||
}
|
||||
return msg.replace(/\n+$|\s{2,}/g, "").trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses collection json file for given path:context.path, and validates
|
||||
* the parsed collectiona array.
|
||||
* @param path Collection json file path.
|
||||
* @returns For successful parsing we get array of HoppCollection<HoppRESTRequest>,
|
||||
*/
|
||||
export const parseCollectionData = (
|
||||
path: string
|
||||
): TE.TaskEither<HoppCLIError, HoppCollection<HoppRESTRequest>[]> =>
|
||||
pipe(
|
||||
// Trying to read give collection json path.
|
||||
TE.tryCatch(
|
||||
() => pipe(path, fs.readFile),
|
||||
(reason) => error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
|
||||
),
|
||||
|
||||
// Checking if parsed file data is array.
|
||||
TE.chainEitherKW((data) =>
|
||||
pipe(
|
||||
data.toString(),
|
||||
J.parse,
|
||||
E.map((jsonData) => (Array.isArray(jsonData) ? jsonData : [jsonData])),
|
||||
E.mapLeft((e) =>
|
||||
error({ code: "MALFORMED_COLLECTION", path, data: E.toError(e) })
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Validating collections to be HoppRESTCollection.
|
||||
TE.chainW(
|
||||
TE.fromPredicate(A.every(isRESTCollection), () =>
|
||||
error({
|
||||
code: "MALFORMED_COLLECTION",
|
||||
path,
|
||||
data: "Please check the collection data.",
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
268
packages/hoppscotch-cli/src/utils/pre-request.ts
Normal file
268
packages/hoppscotch-cli/src/utils/pre-request.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
Environment,
|
||||
HoppRESTRequest,
|
||||
parseBodyEnvVariablesE,
|
||||
parseRawKeyValueEntriesE,
|
||||
parseTemplateString,
|
||||
parseTemplateStringE,
|
||||
} from "@hoppscotch/data";
|
||||
import { runPreRequestScript } from "@hoppscotch/js-sandbox";
|
||||
import { flow, pipe } from "fp-ts/function";
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import * as E from "fp-ts/Either";
|
||||
import * as RA from "fp-ts/ReadonlyArray";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as O from "fp-ts/Option";
|
||||
import * as S from "fp-ts/string";
|
||||
import qs from "qs";
|
||||
import { EffectiveHoppRESTRequest } from "../interfaces/request";
|
||||
import { error, HoppCLIError } from "../types/errors";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { isHoppCLIError } from "./checks";
|
||||
import { tupleToRecord, arraySort, arrayFlatMap } from "./functions/array";
|
||||
import { toFormData } from "./mutators";
|
||||
import { getEffectiveFinalMetaData } from "./getters";
|
||||
|
||||
/**
|
||||
* Runs pre-request-script runner over given request which extracts set ENVs and
|
||||
* applies them on current request to generate updated request.
|
||||
* @param request HoppRESTRequest to be converted to EffectiveHoppRESTRequest.
|
||||
* @param envs Environment variables related to request.
|
||||
* @returns EffectiveHoppRESTRequest that includes parsed ENV variables with in
|
||||
* request OR HoppCLIError with error code and related information.
|
||||
*/
|
||||
export const preRequestScriptRunner = (
|
||||
request: HoppRESTRequest,
|
||||
envs: HoppEnvs
|
||||
): TE.TaskEither<HoppCLIError, EffectiveHoppRESTRequest> =>
|
||||
pipe(
|
||||
TE.of(request),
|
||||
TE.chain(({ preRequestScript }) =>
|
||||
runPreRequestScript(preRequestScript, envs)
|
||||
),
|
||||
TE.map(
|
||||
({ selected, global }) =>
|
||||
<Environment>{ name: "Env", variables: [...selected, ...global] }
|
||||
),
|
||||
TE.chainEitherKW((env) => getEffectiveRESTRequest(request, env)),
|
||||
TE.mapLeft((reason) =>
|
||||
isHoppCLIError(reason)
|
||||
? reason
|
||||
: error({
|
||||
code: "PRE_REQUEST_SCRIPT_ERROR",
|
||||
data: reason,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Outputs an executable request format with environment variables applied
|
||||
*
|
||||
* @param request The request to source from
|
||||
* @param environment The environment to apply
|
||||
*
|
||||
* @returns An object with extra fields defining a complete request
|
||||
*/
|
||||
export function getEffectiveRESTRequest(
|
||||
request: HoppRESTRequest,
|
||||
environment: Environment
|
||||
): E.Either<HoppCLIError, EffectiveHoppRESTRequest> {
|
||||
const envVariables = environment.variables;
|
||||
|
||||
// Parsing final headers with applied ENVs.
|
||||
const _effectiveFinalHeaders = getEffectiveFinalMetaData(
|
||||
request.headers,
|
||||
environment
|
||||
);
|
||||
if (E.isLeft(_effectiveFinalHeaders)) {
|
||||
return _effectiveFinalHeaders;
|
||||
}
|
||||
const effectiveFinalHeaders = _effectiveFinalHeaders.right;
|
||||
|
||||
// Parsing final parameters with applied ENVs.
|
||||
const _effectiveFinalParams = getEffectiveFinalMetaData(
|
||||
request.params,
|
||||
environment
|
||||
);
|
||||
if (E.isLeft(_effectiveFinalParams)) {
|
||||
return _effectiveFinalParams;
|
||||
}
|
||||
const effectiveFinalParams = _effectiveFinalParams.right;
|
||||
|
||||
// Authentication
|
||||
if (request.auth.authActive) {
|
||||
// TODO: Support a better b64 implementation than btoa ?
|
||||
if (request.auth.authType === "basic") {
|
||||
const username = parseTemplateString(request.auth.username, envVariables);
|
||||
const password = parseTemplateString(request.auth.password, envVariables);
|
||||
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Basic ${btoa(`${username}:${password}`)}`,
|
||||
});
|
||||
} else if (
|
||||
request.auth.authType === "bearer" ||
|
||||
request.auth.authType === "oauth-2"
|
||||
) {
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${parseTemplateString(
|
||||
request.auth.token,
|
||||
envVariables
|
||||
)}`,
|
||||
});
|
||||
} else if (request.auth.authType === "api-key") {
|
||||
const { key, value, addTo } = request.auth;
|
||||
if (addTo === "Headers") {
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: parseTemplateString(key, envVariables),
|
||||
value: parseTemplateString(value, envVariables),
|
||||
});
|
||||
} else if (addTo === "Query params") {
|
||||
effectiveFinalParams.push({
|
||||
active: true,
|
||||
key: parseTemplateString(key, envVariables),
|
||||
value: parseTemplateString(value, envVariables),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing final-body with applied ENVs.
|
||||
const _effectiveFinalBody = getFinalBodyFromRequest(request, envVariables);
|
||||
if (E.isLeft(_effectiveFinalBody)) {
|
||||
return _effectiveFinalBody;
|
||||
}
|
||||
const effectiveFinalBody = _effectiveFinalBody.right;
|
||||
|
||||
if (request.body.contentType)
|
||||
effectiveFinalHeaders.push({
|
||||
active: true,
|
||||
key: "content-type",
|
||||
value: request.body.contentType,
|
||||
});
|
||||
|
||||
// Parsing final-endpoint with applied ENVs.
|
||||
const _effectiveFinalURL = parseTemplateStringE(
|
||||
request.endpoint,
|
||||
envVariables
|
||||
);
|
||||
if (E.isLeft(_effectiveFinalURL)) {
|
||||
return E.left(
|
||||
error({
|
||||
code: "PARSING_ERROR",
|
||||
data: `${request.endpoint} (${_effectiveFinalURL.left})`,
|
||||
})
|
||||
);
|
||||
}
|
||||
const effectiveFinalURL = _effectiveFinalURL.right;
|
||||
|
||||
return E.right({
|
||||
...request,
|
||||
effectiveFinalURL,
|
||||
effectiveFinalHeaders,
|
||||
effectiveFinalParams,
|
||||
effectiveFinalBody,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces template variables in request's body from the given set of ENVs,
|
||||
* to generate final request body without any template variables.
|
||||
* @param request Provides request's body, on which ENVs has to be applied.
|
||||
* @param envVariables Provides set of key-value pairs (environment variables),
|
||||
* used to parse-out template variables.
|
||||
* @returns Final request body without any template variables as value.
|
||||
* Or, HoppCLIError in case of error while parsing.
|
||||
*/
|
||||
function getFinalBodyFromRequest(
|
||||
request: HoppRESTRequest,
|
||||
envVariables: Environment["variables"]
|
||||
): E.Either<HoppCLIError, string | null | FormData> {
|
||||
if (request.body.contentType === null) {
|
||||
return E.right(null);
|
||||
}
|
||||
|
||||
if (request.body.contentType === "application/x-www-form-urlencoded") {
|
||||
return pipe(
|
||||
request.body.body,
|
||||
parseRawKeyValueEntriesE,
|
||||
E.map(
|
||||
flow(
|
||||
RA.toArray,
|
||||
|
||||
/**
|
||||
* Filtering out empty keys and non-active pairs.
|
||||
*/
|
||||
A.filter(({ active, key }) => active && !S.isEmpty(key)),
|
||||
|
||||
/**
|
||||
* Mapping each key-value to template-string-parser with either on array,
|
||||
* which will be resolved in further steps.
|
||||
*/
|
||||
A.map(({ key, value }) => [
|
||||
parseTemplateStringE(key, envVariables),
|
||||
parseTemplateStringE(value, envVariables),
|
||||
]),
|
||||
|
||||
/**
|
||||
* Filtering and mapping only right-eithers for each key-value as [string, string].
|
||||
*/
|
||||
A.filterMap(([key, value]) =>
|
||||
E.isRight(key) && E.isRight(value)
|
||||
? O.some([key.right, value.right] as [string, string])
|
||||
: O.none
|
||||
),
|
||||
tupleToRecord,
|
||||
qs.stringify
|
||||
)
|
||||
),
|
||||
E.mapLeft((e) => error({ code: "PARSING_ERROR", data: e.message }))
|
||||
);
|
||||
}
|
||||
|
||||
if (request.body.contentType === "multipart/form-data") {
|
||||
return pipe(
|
||||
request.body.body,
|
||||
A.filter((x) => x.key !== "" && x.active), // Remove empty keys
|
||||
|
||||
// Sort files down
|
||||
arraySort((a, b) => {
|
||||
if (a.isFile) return 1;
|
||||
if (b.isFile) return -1;
|
||||
return 0;
|
||||
}),
|
||||
|
||||
// FormData allows only a single blob in an entry,
|
||||
// we split array blobs into separate entries (FormData will then join them together during exec)
|
||||
arrayFlatMap((x) =>
|
||||
x.isFile
|
||||
? x.value.map((v) => ({
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: v as string | Blob,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: parseTemplateString(x.value, envVariables),
|
||||
},
|
||||
]
|
||||
),
|
||||
toFormData,
|
||||
E.right
|
||||
);
|
||||
}
|
||||
|
||||
return pipe(
|
||||
parseBodyEnvVariablesE(request.body.body, envVariables),
|
||||
E.mapLeft((e) =>
|
||||
error({
|
||||
code: "PARSING_ERROR",
|
||||
data: `${request.body.body} (${e})`,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
321
packages/hoppscotch-cli/src/utils/request.ts
Normal file
321
packages/hoppscotch-cli/src/utils/request.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import axios, { Method } from "axios";
|
||||
import { URL } from "url";
|
||||
import * as S from "fp-ts/string";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as T from "fp-ts/Task";
|
||||
import * as E from "fp-ts/Either";
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import { HoppRESTRequest } from "@hoppscotch/data";
|
||||
import { responseErrors } from "./constants";
|
||||
import { getMetaDataPairs } from "./getters";
|
||||
import { testRunner, getTestScriptParams, hasFailedTestCases } from "./test";
|
||||
import { RequestConfig, EffectiveHoppRESTRequest } from "../interfaces/request";
|
||||
import { RequestRunnerResponse } from "../interfaces/response";
|
||||
import { preRequestScriptRunner } from "./pre-request";
|
||||
import { HoppEnvs, RequestReport } from "../types/request";
|
||||
import {
|
||||
printPreRequestRunner,
|
||||
printRequestRunner,
|
||||
printTestRunner,
|
||||
printTestSuitesReport,
|
||||
} from "./display";
|
||||
import { error, HoppCLIError } from "../types/errors";
|
||||
|
||||
// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported
|
||||
|
||||
/**
|
||||
* Transforms given request data to request-config used by request-runner to
|
||||
* perform HTTP request.
|
||||
* @param req Effective request data with parsed ENVs.
|
||||
* @returns Request config with data realted to HTTP request.
|
||||
*/
|
||||
export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => {
|
||||
const config: RequestConfig = {
|
||||
supported: true,
|
||||
};
|
||||
const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest;
|
||||
const reqParams = finalParams(req);
|
||||
const reqHeaders = finalHeaders(req);
|
||||
config.url = finalEndpoint(req);
|
||||
config.method = req.method as Method;
|
||||
config.params = getMetaDataPairs(reqParams);
|
||||
config.headers = getMetaDataPairs(reqHeaders);
|
||||
if (req.auth.authActive) {
|
||||
switch (req.auth.authType) {
|
||||
case "oauth-2": {
|
||||
// TODO: OAuth2 Request Parsing
|
||||
// !NOTE: Temporary `config.supported` check
|
||||
config.supported = false;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (req.body.contentType) {
|
||||
config.headers["Content-Type"] = req.body.contentType;
|
||||
switch (req.body.contentType) {
|
||||
case "multipart/form-data": {
|
||||
// TODO: Parse Multipart Form Data
|
||||
// !NOTE: Temporary `config.supported` check
|
||||
config.supported = false;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
config.data = finalBody(req);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs http request using axios with given requestConfig axios
|
||||
* parameters.
|
||||
* @param requestConfig The axios request config.
|
||||
* @returns If successfully ran, we get runner-response including HTTP response data.
|
||||
* Else, HoppCLIError with appropriate error code & data.
|
||||
*/
|
||||
export const requestRunner =
|
||||
(
|
||||
requestConfig: RequestConfig
|
||||
): TE.TaskEither<HoppCLIError, RequestRunnerResponse> =>
|
||||
async () => {
|
||||
try {
|
||||
// NOTE: Temporary parsing check for request endpoint.
|
||||
requestConfig.url = new URL(requestConfig.url ?? "").toString();
|
||||
|
||||
let status: number;
|
||||
const baseResponse = await axios(requestConfig);
|
||||
const { config } = baseResponse;
|
||||
const runnerResponse: RequestRunnerResponse = {
|
||||
...baseResponse,
|
||||
endpoint: getRequest.endpoint(config.url),
|
||||
method: getRequest.method(config.method),
|
||||
body: baseResponse.data,
|
||||
};
|
||||
|
||||
// !NOTE: Temporary `config.supported` check
|
||||
if ((config as RequestConfig).supported === false) {
|
||||
status = 501;
|
||||
runnerResponse.status = status;
|
||||
runnerResponse.statusText = responseErrors[status];
|
||||
}
|
||||
|
||||
return E.right(runnerResponse);
|
||||
} catch (e) {
|
||||
let status: number;
|
||||
const runnerResponse: RequestRunnerResponse = {
|
||||
endpoint: "",
|
||||
method: "GET",
|
||||
body: {},
|
||||
statusText: responseErrors[400],
|
||||
status: 400,
|
||||
headers: [],
|
||||
};
|
||||
|
||||
if (axios.isAxiosError(e)) {
|
||||
runnerResponse.endpoint = e.config.url ?? "";
|
||||
|
||||
if (e.response) {
|
||||
const { data, status, statusText, headers } = e.response;
|
||||
runnerResponse.body = data;
|
||||
runnerResponse.statusText = statusText;
|
||||
runnerResponse.status = status;
|
||||
runnerResponse.headers = headers;
|
||||
} else if ((e.config as RequestConfig).supported === false) {
|
||||
status = 501;
|
||||
runnerResponse.status = status;
|
||||
runnerResponse.statusText = responseErrors[status];
|
||||
} else if (e.request) {
|
||||
return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) }));
|
||||
}
|
||||
|
||||
return E.right(runnerResponse);
|
||||
}
|
||||
|
||||
return E.left(error({ code: "REQUEST_ERROR", data: E.toError(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Getter object methods for request-runner.
|
||||
*/
|
||||
const getRequest = {
|
||||
method: (value: string | undefined) =>
|
||||
value ? (value.toUpperCase() as Method) : "GET",
|
||||
|
||||
endpoint: (value: string | undefined): string => (value ? value : ""),
|
||||
|
||||
finalEndpoint: (req: EffectiveHoppRESTRequest): string =>
|
||||
S.isEmpty(req.effectiveFinalURL) ? req.endpoint : req.effectiveFinalURL,
|
||||
|
||||
finalHeaders: (req: EffectiveHoppRESTRequest) =>
|
||||
A.isNonEmpty(req.effectiveFinalHeaders)
|
||||
? req.effectiveFinalHeaders
|
||||
: req.headers,
|
||||
|
||||
finalParams: (req: EffectiveHoppRESTRequest) =>
|
||||
A.isNonEmpty(req.effectiveFinalParams)
|
||||
? req.effectiveFinalParams
|
||||
: req.params,
|
||||
|
||||
finalBody: (req: EffectiveHoppRESTRequest) =>
|
||||
req.effectiveFinalBody ? req.effectiveFinalBody : req.body.body,
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes given request, which includes executing pre-request-script,
|
||||
* running request & executing test-script.
|
||||
* @param request Request to be processed.
|
||||
* @param envs Global + selected envs used by requests with in collection.
|
||||
* @returns Updated envs and current request's report.
|
||||
*/
|
||||
export const processRequest =
|
||||
(
|
||||
request: HoppRESTRequest,
|
||||
envs: HoppEnvs,
|
||||
path: string
|
||||
): T.Task<{ envs: HoppEnvs; report: RequestReport }> =>
|
||||
async () => {
|
||||
// Initialising updatedEnvs with given parameter envs, will eventually get updated.
|
||||
const result = {
|
||||
envs: <HoppEnvs>envs,
|
||||
report: <RequestReport>{},
|
||||
};
|
||||
|
||||
// Initial value for current request's report with default values for properties.
|
||||
const report: RequestReport = {
|
||||
path: path,
|
||||
tests: [],
|
||||
errors: [],
|
||||
result: true,
|
||||
};
|
||||
|
||||
// Initial value for effective-request with default values for properties.
|
||||
let effectiveRequest = <EffectiveHoppRESTRequest>{
|
||||
...request,
|
||||
effectiveFinalBody: null,
|
||||
effectiveFinalHeaders: [],
|
||||
effectiveFinalParams: [],
|
||||
effectiveFinalURL: "",
|
||||
};
|
||||
|
||||
// Executing pre-request-script
|
||||
const preRequestRes = await preRequestScriptRunner(request, envs)();
|
||||
if (E.isLeft(preRequestRes)) {
|
||||
printPreRequestRunner.fail();
|
||||
|
||||
// Updating report for errors & current result
|
||||
report.errors.push(preRequestRes.left);
|
||||
report.result = report.result && false;
|
||||
} else {
|
||||
// Updating effective-request
|
||||
effectiveRequest = preRequestRes.right;
|
||||
}
|
||||
|
||||
// Creating request-config for request-runner.
|
||||
const requestConfig = createRequest(effectiveRequest);
|
||||
|
||||
printRequestRunner.start(requestConfig);
|
||||
|
||||
// Default value for request-runner's response.
|
||||
let _requestRunnerRes: RequestRunnerResponse = {
|
||||
endpoint: "",
|
||||
method: "GET",
|
||||
headers: [],
|
||||
status: 400,
|
||||
statusText: "",
|
||||
body: Object(null),
|
||||
};
|
||||
// Executing request-runner.
|
||||
const requestRunnerRes = await requestRunner(requestConfig)();
|
||||
if (E.isLeft(requestRunnerRes)) {
|
||||
// Updating report for errors & current result
|
||||
report.errors.push(requestRunnerRes.left);
|
||||
report.result = report.result && false;
|
||||
|
||||
printRequestRunner.fail();
|
||||
} else {
|
||||
_requestRunnerRes = requestRunnerRes.right;
|
||||
printRequestRunner.success(_requestRunnerRes);
|
||||
}
|
||||
|
||||
// Extracting test-script-runner parameters.
|
||||
const testScriptParams = getTestScriptParams(
|
||||
_requestRunnerRes,
|
||||
request,
|
||||
envs
|
||||
);
|
||||
|
||||
// Executing test-runner.
|
||||
const testRunnerRes = await testRunner(testScriptParams)();
|
||||
if (E.isLeft(testRunnerRes)) {
|
||||
printTestRunner.fail();
|
||||
|
||||
// Updating report with current errors & result.
|
||||
report.errors.push(testRunnerRes.left);
|
||||
report.result = report.result && false;
|
||||
} else {
|
||||
const { envs, testsReport } = testRunnerRes.right;
|
||||
const _hasFailedTestCases = hasFailedTestCases(testsReport);
|
||||
|
||||
// Updating report with current tests & result.
|
||||
report.tests = testsReport;
|
||||
report.result = report.result && _hasFailedTestCases;
|
||||
|
||||
// Updating resulting envs from test-runner.
|
||||
result.envs = envs;
|
||||
|
||||
printTestSuitesReport(testsReport);
|
||||
}
|
||||
|
||||
result.report = report;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates new request without any missing/invalid data using
|
||||
* current request object.
|
||||
* @param request Hopp rest request to be processed.
|
||||
* @returns Updated request object free of invalid/missing data.
|
||||
*/
|
||||
export const preProcessRequest = (
|
||||
request: HoppRESTRequest
|
||||
): HoppRESTRequest => {
|
||||
const tempRequest = Object.assign({}, request);
|
||||
if (!tempRequest.v) {
|
||||
tempRequest.v = "1";
|
||||
}
|
||||
if (!tempRequest.name) {
|
||||
tempRequest.name = "Untitled Request";
|
||||
}
|
||||
if (!tempRequest.method) {
|
||||
tempRequest.method = "GET";
|
||||
}
|
||||
if (!tempRequest.endpoint) {
|
||||
tempRequest.endpoint = "";
|
||||
}
|
||||
if (!tempRequest.params) {
|
||||
tempRequest.params = [];
|
||||
}
|
||||
if (!tempRequest.headers) {
|
||||
tempRequest.headers = [];
|
||||
}
|
||||
if (!tempRequest.preRequestScript) {
|
||||
tempRequest.preRequestScript = "";
|
||||
}
|
||||
if (!tempRequest.testScript) {
|
||||
tempRequest.testScript = "";
|
||||
}
|
||||
if (!tempRequest.auth) {
|
||||
tempRequest.auth = { authActive: false, authType: "none" };
|
||||
}
|
||||
if (!tempRequest.body) {
|
||||
tempRequest.body = { contentType: null, body: null };
|
||||
}
|
||||
return tempRequest;
|
||||
};
|
||||
197
packages/hoppscotch-cli/src/utils/test.ts
Normal file
197
packages/hoppscotch-cli/src/utils/test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { HoppRESTRequest } from "@hoppscotch/data";
|
||||
import { execTestScript, TestDescriptor } from "@hoppscotch/js-sandbox";
|
||||
import { flow, pipe } from "fp-ts/function";
|
||||
import * as RA from "fp-ts/ReadonlyArray";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import * as T from "fp-ts/Task";
|
||||
import {
|
||||
RequestRunnerResponse,
|
||||
TestReport,
|
||||
TestScriptParams,
|
||||
} from "../interfaces/response";
|
||||
import { error, HoppCLIError } from "../types/errors";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
|
||||
|
||||
/**
|
||||
* Executes test script and runs testDescriptorParser to generate test-report using
|
||||
* expected-results, test-status & test-descriptor.
|
||||
* @param testScriptData Parameters related to test-script function.
|
||||
* @returns If executes successfully, we get TestRunnerRes(updated ENVs + test-reports).
|
||||
* Else, HoppCLIError with appropriate code & data.
|
||||
*/
|
||||
export const testRunner = (
|
||||
testScriptData: TestScriptParams
|
||||
): TE.TaskEither<HoppCLIError, TestRunnerRes> =>
|
||||
pipe(
|
||||
/**
|
||||
* Executing test-script.
|
||||
*/
|
||||
TE.of(testScriptData),
|
||||
TE.chain(({ testScript, response, envs }) =>
|
||||
execTestScript(testScript, envs, response)
|
||||
),
|
||||
|
||||
/**
|
||||
* Recursively parsing test-results using test-descriptor-parser
|
||||
* to generate test-reports.
|
||||
*/
|
||||
TE.chainTaskK(({ envs, tests }) =>
|
||||
pipe(
|
||||
tests,
|
||||
A.map(testDescriptorParser),
|
||||
T.sequenceArray,
|
||||
T.map(
|
||||
flow(
|
||||
RA.flatten,
|
||||
RA.toArray,
|
||||
(testsReport) => <TestRunnerRes>{ envs, testsReport }
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
TE.mapLeft((e) =>
|
||||
error({
|
||||
code: "TEST_SCRIPT_ERROR",
|
||||
data: e,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Recursive function to parse test-descriptor from nested-children and
|
||||
* generate tests-report.
|
||||
* @param testDescriptor Object with details of test-descriptor.
|
||||
* @returns Flattened array of TestReport parsed from TestDescriptor.
|
||||
*/
|
||||
export const testDescriptorParser = (
|
||||
testDescriptor: TestDescriptor
|
||||
): T.Task<TestReport[]> =>
|
||||
pipe(
|
||||
/**
|
||||
* Generate single TestReport from given testDescriptor.
|
||||
*/
|
||||
testDescriptor,
|
||||
({ expectResults, descriptor }) =>
|
||||
A.isNonEmpty(expectResults)
|
||||
? pipe(
|
||||
expectResults,
|
||||
A.reduce({ failing: 0, passing: 0 }, (prev, { status }) =>
|
||||
/**
|
||||
* Incrementing number of passed test-cases if status is "pass",
|
||||
* else, incrementing number of failed test-cases.
|
||||
*/
|
||||
status === "pass"
|
||||
? { failing: prev.failing, passing: prev.passing + 1 }
|
||||
: { failing: prev.failing + 1, passing: prev.passing }
|
||||
),
|
||||
({ failing, passing }) =>
|
||||
<TestReport>{
|
||||
failing,
|
||||
passing,
|
||||
descriptor,
|
||||
expectResults,
|
||||
},
|
||||
Array.of
|
||||
)
|
||||
: [],
|
||||
T.of,
|
||||
|
||||
/**
|
||||
* Recursive call to testDescriptorParser on testDescriptor's children.
|
||||
* The result is concated with previous testReport.
|
||||
*/
|
||||
T.chain((testReport) =>
|
||||
pipe(
|
||||
testDescriptor.children,
|
||||
A.map(testDescriptorParser),
|
||||
T.sequenceArray,
|
||||
T.map(flow(RA.flatten, RA.toArray, A.concat(testReport)))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Extracts parameter object from request-runner's response, request and envs
|
||||
* for test-runner.
|
||||
* @param reqRunnerRes Provides response data.
|
||||
* @param request Provides test-script data.
|
||||
* @param envs Current ENVs state with-in collections-runner.
|
||||
* @returns Object to be passed as parameter for test-runner
|
||||
*/
|
||||
export const getTestScriptParams = (
|
||||
reqRunnerRes: RequestRunnerResponse,
|
||||
request: HoppRESTRequest,
|
||||
envs: HoppEnvs
|
||||
) => {
|
||||
const testScriptParams: TestScriptParams = {
|
||||
testScript: request.testScript,
|
||||
response: {
|
||||
body: reqRunnerRes.body,
|
||||
status: reqRunnerRes.status,
|
||||
headers: reqRunnerRes.headers,
|
||||
},
|
||||
envs: envs,
|
||||
};
|
||||
return testScriptParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* Combines quantitative details (test-cases passed/failed) of each test-report
|
||||
* to generate TestMetrics object with total test-cases & total test-suites.
|
||||
* @param testsReport Contains details of each test-report (failed/passed test-cases).
|
||||
* @returns Object containing details of total test-cases passed/failed and
|
||||
* total test-suites passed/failed.
|
||||
*/
|
||||
export const getTestMetrics = (testsReport: TestReport[]): TestMetrics =>
|
||||
testsReport.reduce(
|
||||
({ testSuites, tests }, testReport) => ({
|
||||
tests: {
|
||||
failing: tests.failing + testReport.failing,
|
||||
passing: tests.passing + testReport.passing,
|
||||
},
|
||||
testSuites: {
|
||||
failing: testSuites.failing + (testReport.failing > 0 ? 1 : 0),
|
||||
passing: testSuites.passing + (testReport.failing === 0 ? 1 : 0),
|
||||
},
|
||||
}),
|
||||
<TestMetrics>{
|
||||
tests: { failing: 0, passing: 0 },
|
||||
testSuites: { failing: 0, passing: 0 },
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters tests-report containing atleast one or more failed test-cases.
|
||||
* @param testsReport Provides "failing" test-cases data.
|
||||
* @returns Tests report with one or more test-cases failing.
|
||||
*/
|
||||
export const getFailedTestsReport = (testsReport: TestReport[]) =>
|
||||
pipe(
|
||||
testsReport,
|
||||
A.filter(({ failing }) => failing > 0)
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters expected-results containing which has status as "fail" or "error".
|
||||
* @param expectResults Provides "status" data for each expected result.
|
||||
* @returns Expected results with "fail" or "error" status.
|
||||
*/
|
||||
export const getFailedExpectedResults = (expectResults: ExpectResult[]) =>
|
||||
pipe(
|
||||
expectResults,
|
||||
A.filter(({ status }) => status !== "pass")
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if any of the tests-report have failed test-cases.
|
||||
* @param testsReport Provides "failing" test-cases data.
|
||||
* @returns True, if one or more failed test-cases found.
|
||||
* False, if all test-cases passed.
|
||||
*/
|
||||
export const hasFailedTestCases = (testsReport: TestReport[]) =>
|
||||
pipe(
|
||||
testsReport,
|
||||
A.every(({ failing }) => failing === 0)
|
||||
);
|
||||
Reference in New Issue
Block a user