refactor: cli updates (#2907)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Jesvin Jose
2023-02-07 17:47:54 +05:30
committed by GitHub
parent f676f94278
commit cd72851289
13 changed files with 137 additions and 271 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/cli",
"version": "0.3.0",
"version": "0.3.1",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"main": "dist/index.js",

View File

@@ -5,42 +5,52 @@ import { execAsync, getErrorCode, getTestJsonFilePath } from "../utils";
describe("Test 'hopp test <file>' command:", () => {
test("No collection file path provided.", async () => {
const cmd = `node ./bin/hopp test`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Collection file not found.", async () => {
const cmd = `node ./bin/hopp test notfound.json`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("Malformed collection file.", async () => {
test("Collection file is invalid JSON.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
"malformed-collection.json"
)}`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
});
test("Malformed collection file.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
"malformed-collection2.json"
)}`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
});
test("Invalid arguement.", async () => {
const cmd = `node ./bin/hopp invalid-arg`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Collection file not JSON type.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
@@ -70,24 +80,24 @@ describe("Test 'hopp test <file> --env <file>' command:", () => {
test("No env file path provided.", async () => {
const cmd = `${VALID_TEST_CMD} --env`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("ENV file not JSON type.", async () => {
const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
test("ENV file not found.", async () => {
const cmd = `${VALID_TEST_CMD} --env notfound.json`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
@@ -109,17 +119,17 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
test("No value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Invalid value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
const { stdout } = await execAsync(cmd);
const out = getErrorCode(stdout);
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
console.log("invalid value thing", out)
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});

View File

@@ -1,28 +0,0 @@
import { HoppCLIError } from "../../../types/errors";
import { checkFile } from "../../../utils/checks";
import "@relmify/jest-fp-ts";
describe("checkFile", () => {
test("File doesn't exists.", () => {
return expect(
checkFile("./src/samples/this-file-not-exists.json")()
).resolves.toSubsetEqualLeft(<HoppCLIError>{
code: "FILE_NOT_FOUND",
});
});
test("File not of JSON type.", () => {
return expect(
checkFile("./src/__tests__/samples/notjson.txt")()
).resolves.toSubsetEqualLeft(<HoppCLIError>{
code: "INVALID_FILE_TYPE",
});
});
test("Existing JSON file.", () => {
return expect(
checkFile("./src/__tests__/samples/passes.json")()
).resolves.toBeRight();
});
});

View File

@@ -50,7 +50,7 @@ describe("collectionsRunner", () => {
test("Empty HoppCollection.", () => {
return expect(
collectionsRunner({ collections: [], envs: SAMPLE_ENVS })()
collectionsRunner({ collections: [], envs: SAMPLE_ENVS })
).resolves.toStrictEqual([]);
});
@@ -66,7 +66,7 @@ describe("collectionsRunner", () => {
},
],
envs: SAMPLE_ENVS,
})()
})
).resolves.toMatchObject([]);
});
@@ -84,7 +84,7 @@ describe("collectionsRunner", () => {
},
],
envs: SAMPLE_ENVS,
})()
})
).resolves.toMatchObject([
{
path: "collection/request",
@@ -116,7 +116,7 @@ describe("collectionsRunner", () => {
},
],
envs: SAMPLE_ENVS,
})()
})
).resolves.toMatchObject([
{
path: "collection/folder/request",

View File

@@ -1,22 +1,20 @@
import { HoppCLIError } from "../../../types/errors";
import { parseCollectionData } from "../../../utils/mutators";
import "@relmify/jest-fp-ts";
describe("parseCollectionData", () => {
test("Reading non-existing file.", () => {
return expect(
parseCollectionData("./src/__tests__/samples/notexist.json")()
).resolves.toSubsetEqualLeft(<HoppCLIError>{
parseCollectionData("./src/__tests__/samples/notexist.json")
).rejects.toMatchObject(<HoppCLIError>{
code: "FILE_NOT_FOUND",
});
});
test("Unparseable JSON contents.", () => {
return expect(
parseCollectionData("./src/__tests__/samples/malformed-collection.json")()
).resolves.toSubsetEqualLeft(<HoppCLIError>{
code: "MALFORMED_COLLECTION",
parseCollectionData("./src/__tests__/samples/malformed-collection.json")
).rejects.toMatchObject(<HoppCLIError>{
code: "UNKNOWN_ERROR",
});
});
@@ -24,15 +22,15 @@ describe("parseCollectionData", () => {
return expect(
parseCollectionData(
"./src/__tests__/samples/malformed-collection2.json"
)()
).resolves.toSubsetEqualLeft(<HoppCLIError>{
)
).rejects.toMatchObject(<HoppCLIError>{
code: "MALFORMED_COLLECTION",
});
});
test("Valid HoppCollection.", () => {
return expect(
parseCollectionData("./src/__tests__/samples/passes.json")()
).resolves.toBeRight();
parseCollectionData("./src/__tests__/samples/passes.json")
).resolves.toBeTruthy();
});
});

View File

@@ -1,5 +1,3 @@
import * as TE from "fp-ts/TaskEither";
import { pipe, flow } from "fp-ts/function";
import {
collectionsRunner,
collectionsRunnerExit,
@@ -10,18 +8,23 @@ import { parseCollectionData } from "../utils/mutators";
import { parseEnvsData } from "../options/test/env";
import { TestCmdOptions } from "../types/commands";
import { parseDelayOption } from "../options/test/delay";
import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "../utils/checks";
export const test = (path: string, options: TestCmdOptions) => async () => {
await pipe(
TE.Do,
TE.bind("envs", () => parseEnvsData(options.env)),
TE.bind("collections", () => parseCollectionData(path)),
TE.bind("delay", () => parseDelayOption(options.delay)),
TE.chainTaskK(collectionsRunner),
TE.chainW(flow(collectionsRunnerResult, collectionsRunnerExit, TE.of)),
TE.mapLeft((e) => {
handleError(e);
try {
const delay = options.delay ? parseDelayOption(options.delay) : 0
const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] }
const collections = await parseCollectionData(path)
const report = await collectionsRunner({collections, envs, delay})
const hasSucceeded = collectionsRunnerResult(report)
collectionsRunnerExit(hasSucceeded)
} catch(e) {
if(isHoppCLIError(e)) {
handleError(e)
process.exit(1);
})
)();
}
else throw e
}
};

View File

@@ -1,4 +1,3 @@
import { log } from "console";
import * as S from "fp-ts/string";
import { HoppError, HoppErrorCode } from "../types/errors";
import { hasProperty, isSafeCommanderError } from "../utils/checks";
@@ -7,7 +6,7 @@ import { exceptionColors } from "../utils/getters";
const { BG_FAIL } = exceptionColors;
/**
* Parses unknown error data and narrows it to get information realted to
* Parses unknown error data and narrows it to get information related to
* error in string format.
* @param e Error data to parse.
* @returns Information in string format appropriately parsed, based on error type.
@@ -81,6 +80,6 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
}
if (!S.isEmpty(ERROR_MSG)) {
log(ERROR_CODE, ERROR_MSG);
console.error(ERROR_CODE, ERROR_MSG);
}
};
};

View File

@@ -1,20 +1,14 @@
import * as TE from "fp-ts/TaskEither";
import * as S from "fp-ts/string";
import { pipe } from "fp-ts/function";
import { error, HoppCLIError } from "../../types/errors";
import { error } from "../../types/errors";
export const parseDelayOption = (
delay: unknown
): TE.TaskEither<HoppCLIError, number> =>
!S.isString(delay)
? TE.right(0)
: pipe(
delay,
Number,
TE.fromPredicate(Number.isSafeInteger, () =>
error({
code: "INVALID_ARGUMENT",
data: "Expected '-d, --delay' value to be number",
})
)
);
export function parseDelayOption(delay: string): number {
const maybeInt = Number.parseInt(delay)
if(!Number.isNaN(maybeInt)) {
return maybeInt
} else {
throw error({
code: "INVALID_ARGUMENT",
data: "Expected '-d, --delay' value to be number",
})
}
}

View File

@@ -1,64 +1,27 @@
import fs from "fs/promises";
import { pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import * as E from "fp-ts/Either";
import * as J from "fp-ts/Json";
import * as A from "fp-ts/Array";
import * as S from "fp-ts/string";
import isArray from "lodash/isArray";
import { HoppCLIError, error } from "../../types/errors";
import { error } from "../../types/errors";
import { HoppEnvs, HoppEnvPair } from "../../types/request";
import { checkFile } from "../../utils/checks";
import { readJsonFile } from "../../utils/mutators";
/**
* Parses env json file for given path and validates the parsed env json object.
* @param path Path of env.json file to be parsed.
* @returns For successful parsing we get HoppEnvs object.
*/
export const parseEnvsData = (
path: unknown
): TE.TaskEither<HoppCLIError, HoppEnvs> =>
!S.isString(path)
? TE.right({ global: [], selected: [] })
: pipe(
// Checking if the env.json file exists or not.
checkFile(path),
export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path)
// Trying to read given env json file path.
TE.chainW((checkedPath) =>
TE.tryCatch(
() => fs.readFile(checkedPath),
(reason) =>
error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
)
),
if(!(contents && typeof contents === "object" && !Array.isArray(contents))) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: null })
}
// Trying to JSON parse the read file data and mapping the entries to HoppEnvPairs.
TE.chainEitherKW((data) =>
pipe(
data.toString(),
J.parse,
E.map((jsonData) =>
jsonData && typeof jsonData === "object" && !isArray(jsonData)
? pipe(
jsonData,
Object.entries,
A.map(
([key, value]) =>
<HoppEnvPair>{
key,
value: S.isString(value)
? value
: JSON.stringify(value),
}
)
)
: []
),
E.map((envPairs) => <HoppEnvs>{ global: [], selected: envPairs }),
E.mapLeft((e) =>
error({ code: "MALFORMED_ENV_FILE", path, data: E.toError(e) })
)
)
)
);
const envPairs: Array<HoppEnvPair> = []
for( const [key,value] of Object.entries(contents)) {
if(typeof value !== "string") {
throw error({ code: "MALFORMED_ENV_FILE", path, data: {value: value} })
}
envPairs.push({key, value})
}
return <HoppEnvs>{ global: [], selected: envPairs }
}

View File

@@ -1,6 +1,6 @@
export type TestCmdOptions = {
env: string;
delay: number;
env: string | undefined;
delay: string | undefined;
};
export type HoppEnvFileExt = "json";
export type HOPP_ENV_FILE_EXT = "json";

View File

@@ -1,20 +1,11 @@
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 * as E from "fp-ts/Either";
import curryRight from "lodash/curryRight";
import { CommanderError } from "commander";
import { error, HoppCLIError, HoppErrnoException } from "../types/errors";
import { HoppCollectionFileExt } from "../types/collections";
import { HoppEnvFileExt } from "../types/commands";
import { HoppCLIError, HoppErrnoException } from "../types/errors";
/**
* Determines whether an object has a property with given name.
@@ -71,59 +62,6 @@ export const isRESTCollection = (
return false;
};
/**
* Checks if the file path matches the requried file type with of required extension.
* @param path The input file path to check.
* @param extension The required extension for input file path.
* @returns Absolute path for valid file extension OR HoppCLIError in case of error.
*/
export const checkFileExt = curryRight(
(
path: unknown,
extension: HoppCollectionFileExt | HoppEnvFileExt
): E.Either<HoppCLIError, string> =>
pipe(
path,
E.fromPredicate(S.isString, (_) => error({ code: "NO_FILE_PATH" })),
E.chainW(
E.fromPredicate(S.endsWith(`.${extension}`), (_) =>
error({ code: "INVALID_FILE_TYPE", data: extension })
)
)
)
);
/**
* Checks if the given file path exists and is of given type.
* @param path The input file path to check.
* @returns Absolute path for valid file path OR HoppCLIError in case of error.
*/
export const checkFile = (path: unknown): TE.TaskEither<HoppCLIError, string> =>
pipe(
path,
// Checking if path is string.
TE.fromPredicate(S.isString, () => error({ code: "NO_FILE_PATH" })),
/**
* After checking file path, we map file path to absolute path and check
* if file is of given extension type.
*/
TE.map(join),
TE.chainEitherK(checkFileExt("json")),
/**
* 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((checkedPath) =>
TE.tryCatchK(
() => fs.access(checkedPath),
() => error({ code: "FILE_NOT_FOUND", path: checkedPath })
)()
)
);
/**
* Checks if given error data is of type HoppCLIError, based on existence

View File

@@ -1,4 +1,3 @@
import * as T from "fp-ts/Task";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import { bold } from "chalk";
@@ -43,8 +42,8 @@ const { WARN, FAIL } = exceptionColors;
* @returns List of report for each processed request.
*/
export const collectionsRunner =
(param: CollectionRunnerParam): T.Task<RequestReport[]> =>
async () => {
async (param: CollectionRunnerParam): Promise<RequestReport[]> =>
{
const envs: HoppEnvs = param.envs;
const delay = param.delay ?? 0;
const requestsReport: RequestReport[] = [];
@@ -213,10 +212,10 @@ export const collectionsRunnerResult = (
* Else, exit with code 1.
* @param result Boolean defining the collections-runner result.
*/
export const collectionsRunnerExit = (result: boolean) => {
export const collectionsRunnerExit = (result: boolean): never => {
if (!result) {
const EXIT_MSG = FAIL(`\nExited with code 1`);
process.stdout.write(EXIT_MSG);
process.stderr.write(EXIT_MSG);
process.exit(1);
}
process.exit(0);

View File

@@ -1,12 +1,7 @@
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, checkFile } from "./checks";
import { error } from "../types/errors";
import { isRESTCollection, isHoppErrnoException } from "./checks";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
/**
@@ -39,49 +34,44 @@ export const parseErrorMessage = (e: unknown) => {
return msg.replace(/\n+$|\s{2,}/g, "").trim();
};
export async function readJsonFile(path: string): Promise<unknown> {
if(!path.endsWith('.json')) {
throw error({ code: "INVALID_FILE_TYPE", data: path })
}
try {
await fs.access(path)
} catch (e) {
throw error({ code: "FILE_NOT_FOUND", path: path })
}
try {
return JSON.parse((await fs.readFile(path)).toString())
} catch(e) {
throw error({ code: "UNKNOWN_ERROR", data: e })
}
}
/**
* 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 = (
export async function parseCollectionData(
path: string
): TE.TaskEither<HoppCLIError, HoppCollection<HoppRESTRequest>[]> =>
pipe(
TE.of(path),
): Promise<HoppCollection<HoppRESTRequest>[]> {
let contents = await readJsonFile(path)
// Checking if given file path exists or not.
TE.chain(checkFile),
const maybeArrayOfCollections: unknown[] = Array.isArray(contents) ? contents : [contents]
// Trying to read give collection json path.
TE.chainW((checkedPath) =>
TE.tryCatch(
() => fs.readFile(checkedPath),
(reason) => error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
)
),
if(maybeArrayOfCollections.some((x) => !isRESTCollection(x))) {
throw error({
code: "MALFORMED_COLLECTION",
path,
data: "Please check the collection data.",
})
}
// 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.",
})
)
)
);
return maybeArrayOfCollections as HoppCollection<HoppRESTRequest>[]
};