UNPKG

@eeue56/bach

Version:

A simple TypeScript test runner inspired by Pytest.

452 lines (451 loc) 19.1 kB
#!/usr/bin/env ts-node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.runner = exports.centerAndPadding = void 0; const baner_1 = require("@eeue56/baner"); const assert_1 = __importDefault(require("assert")); const fast_glob_1 = __importDefault(require("fast-glob")); const fs_1 = require("fs"); const json5_1 = __importDefault(require("json5")); const path = __importStar(require("path")); const perf_hooks_1 = require("perf_hooks"); const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor; const chalk = { red: function (text) { return `\x1b[31m${text}\x1b[0m`; }, green: function (text) { return `\x1b[32m\x1b[1m${text}\x1b[0m`; }, }; function widestSingleFileCellSizes(results) { const cellSizes = { functionName: " Function name ".length, passed: " Passed? ".length, }; for (const result of results) { if (cellSizes.functionName < result.functionName.length + 2) { cellSizes.functionName = result.functionName.length + 2; } if (cellSizes.passed < `${result.passed}`.length + 2) { cellSizes.passed = `${result.passed}`.length + 2; } } return { functionName: cellSizes.functionName % 2 === 0 ? cellSizes.functionName : cellSizes.functionName + 1, passed: cellSizes.passed % 2 === 0 ? cellSizes.passed : cellSizes.passed + 1, }; } function widestMultipleFileCellSizes(results) { const cellSizes = { fileName: " Function name ".length, passed: " Passed ".length, failed: " Failed ".length, }; for (const result of results) { if (cellSizes.fileName < result.fileName.length + 2) { cellSizes.fileName = result.fileName.length + 2; } if (cellSizes.passed < `${result.passed}`.length + 2) { cellSizes.passed = `${result.passed}`.length + 2; } if (cellSizes.failed < `${result.failed}`.length + 2) { cellSizes.failed = `${result.failed}`.length + 2; } } return { fileName: cellSizes.fileName % 2 === 0 ? cellSizes.fileName : cellSizes.fileName + 1, passed: cellSizes.passed % 2 === 0 ? cellSizes.passed : cellSizes.passed + 1, failed: cellSizes.failed % 2 === 0 ? cellSizes.failed : cellSizes.failed + 1, }; } const horizontal = "─"; const vertical = "│"; const leftJoin = "├"; const middleJoin = "┼"; const rightJoin = "┤"; const leftCorner = "┌"; const rightCorner = "┐"; const bottomLeftCorner = "└"; const bottomRightCorner = "┘"; function centerAndPadding(cellSize, text) { const length = text.length; const eitherSide = Math.floor((cellSize - length) / 2); let extraLeft = 0; if (text.length % 2 === 1) { extraLeft = 1; } return " ".repeat(eitherSide + extraLeft) + text + " ".repeat(eitherSide); } exports.centerAndPadding = centerAndPadding; function viewSingleFileResult(result, tableWidth) { console.log(leftJoin + horizontal.repeat(tableWidth.functionName) + middleJoin + horizontal.repeat(tableWidth.passed) + rightJoin); const colouredFunctionName = result.passed ? chalk.green(centerAndPadding(tableWidth.functionName, `${result.functionName}`)) : chalk.red(centerAndPadding(tableWidth.functionName, `${result.functionName}`)); const colouredPassed = result.passed ? chalk.green(centerAndPadding(tableWidth.passed, `${result.passed}`)) : chalk.red(centerAndPadding(tableWidth.passed, `${result.passed}`)); console.log(vertical + colouredFunctionName + vertical + colouredPassed + vertical); } function viewSingleFileResults(results) { const cellSizes = widestSingleFileCellSizes(results); const headers = vertical + centerAndPadding(cellSizes.functionName, "Function name") + vertical + centerAndPadding(cellSizes.passed, "Passed?") + vertical; console.log(leftCorner + horizontal.repeat(cellSizes.functionName + cellSizes.passed + 1) + rightCorner); console.log(headers); for (const result of results) { viewSingleFileResult(result, cellSizes); } console.log(bottomLeftCorner + horizontal.repeat(cellSizes.functionName) + horizontal.repeat(cellSizes.passed + 1) + bottomRightCorner); } function viewMultipleFileResult(result, tableWidth) { console.log(leftJoin + horizontal.repeat(tableWidth.fileName) + middleJoin + horizontal.repeat(tableWidth.passed) + middleJoin + horizontal.repeat(tableWidth.failed) + rightJoin); const colouredFileName = result.failed === 0 ? chalk.green(centerAndPadding(tableWidth.fileName, `${result.fileName}`)) : chalk.red(centerAndPadding(tableWidth.fileName, `${result.fileName}`)); const colouredPassed = result.passed > 0 ? chalk.green(centerAndPadding(tableWidth.passed, `${result.passed}`)) : chalk.red(centerAndPadding(tableWidth.passed, `${result.passed}`)); const colouredFailed = result.failed === 0 ? chalk.green(centerAndPadding(tableWidth.failed, `${result.failed}`)) : chalk.red(centerAndPadding(tableWidth.failed, `${result.failed}`)); console.log(vertical + colouredFileName + vertical + colouredPassed + vertical + colouredFailed + vertical); } function viewMultipleFileResults(results) { const cellSizes = widestMultipleFileCellSizes(results); const headers = vertical + centerAndPadding(cellSizes.fileName, "File name") + vertical + centerAndPadding(cellSizes.passed, "Passed") + vertical + centerAndPadding(cellSizes.failed, "Failed") + vertical; console.log(leftCorner + horizontal.repeat(cellSizes.fileName + cellSizes.passed + cellSizes.failed + 2) + rightCorner); console.log(headers); for (const result of results) { viewMultipleFileResult(result, cellSizes); } console.log(bottomLeftCorner + horizontal.repeat(cellSizes.fileName + cellSizes.passed + cellSizes.failed + 2) + bottomRightCorner); } function isAsyncFunction(func) { return Object.getPrototypeOf(func).constructor === AsyncFunction; } function getSnapshotFileName(config, fileName, functionName) { return path.join(config.include[0].split("/")[0], `__snapshots__`, path .relative(process.cwd(), fileName) .split(".") .slice(0, -1) .join("."), functionName + ".ts"); } async function updateSnapshots(config, program, filesToProcess, functionNamesToRun) { let totalSnapshotsUpdated = 0; await Promise.all(filesToProcess.map(async (fileName) => { return new Promise(async (resolve, reject) => { fileName = program.flags.file.arguments.kind === "ok" ? path.join(process.cwd(), fileName) : fileName; const baseFileName = path .basename(fileName) .split(".") .slice(0, -1) .join("."); const extension = path.extname(fileName); const isValidExtension = extension === ".js" || extension === ".ts"; if (!baseFileName.endsWith("test") || !isValidExtension) { return resolve(null); } console.log(`Found ${fileName}`); const imported = await Promise.resolve().then(() => __importStar(require(fileName))); for (const functionName of Object.keys(imported)) { if (!functionName.startsWith("snapshot")) { continue; } if (functionNamesToRun && functionNamesToRun.indexOf(functionName) === -1) continue; const func = imported[functionName]; const isAsync = isAsyncFunction(func); totalSnapshotsUpdated += 1; console.log(`Running ${functionName}`); let computedSnapshot; if (isAsync) { computedSnapshot = await func(); } else { computedSnapshot = func(); } const snapshotFileName = getSnapshotFileName(config, fileName, functionName); console.log("writing to ", snapshotFileName); let fileContents = computedSnapshot; const strToWrite = ` export const ${functionName} = ${JSON.stringify(fileContents, null, 4)}; `.trim(); await fs_1.promises.mkdir(path.dirname(snapshotFileName), { recursive: true, }); await fs_1.promises.writeFile(snapshotFileName, strToWrite); } resolve(null); }); })); console.log(`Updated ${totalSnapshotsUpdated} snapshots.`); } async function runner() { const cliParser = (0, baner_1.parser)([ (0, baner_1.longFlag)("function", "Run a specific function", (0, baner_1.variableList)((0, baner_1.string)())), (0, baner_1.longFlag)("file", "Run a specific file", (0, baner_1.variableList)((0, baner_1.string)())), (0, baner_1.longFlag)("clean-exit", "Don't use process.exit even if tests fail", (0, baner_1.empty)()), (0, baner_1.longFlag)("only-fails", "Only show the tests that fail", (0, baner_1.empty)()), (0, baner_1.longFlag)("in-chunks", "Run tests in chunks of N files (suitable for lower memory impact)", (0, baner_1.number)()), (0, baner_1.longFlag)("chunk-start", "Start running chunk at N", (0, baner_1.number)()), (0, baner_1.bothFlag)("u", "update-snapshots", "Update the snapshots and exit", (0, baner_1.empty)()), (0, baner_1.bothFlag)("h", "help", "Displays help message", (0, baner_1.empty)()), ]); const program = (0, baner_1.parse)(cliParser, process.argv); if (program.flags["h/help"].isPresent) { console.log((0, baner_1.help)(cliParser)); return; } const onlyFails = program.flags["only-fails"].isPresent; const functionNamesToRun = program.flags.function.arguments.kind === "ok" ? program.flags.function.arguments.value : null; const fileNamesToRun = program.flags.file.arguments.kind === "ok" ? program.flags.file.arguments.value : null; console.log("Looking for tsconfig..."); const strConfig = (await fs_1.promises.readFile("./tsconfig.json")).toString(); const config = json5_1.default.parse(strConfig); if (!fileNamesToRun) { console.log(`Looking for tests in ${config.include}...`); if (!config.include) { console.error("include was not set in tsconfig, not sure where to look for tests"); console.error("Quitting..."); return; } } else { console.log("Running provided filenames..."); } const files = fileNamesToRun ? fileNamesToRun : await (0, fast_glob_1.default)(config.include, { absolute: true }); const results = {}; let passedTests = 0; let totalTests = 0; const chunks = program.flags["in-chunks"].isPresent ? program.flags["in-chunks"].arguments.value : files.length; const chunkStart = program.flags["chunk-start"].isPresent ? program.flags["chunk-start"].arguments.value : 0; const startTime = perf_hooks_1.performance.now(); const filesToProcess = files.slice(chunkStart, chunkStart + chunks); if (program.flags["u/update-snapshots"].isPresent) { await updateSnapshots(config, program, filesToProcess, functionNamesToRun); return; } await Promise.all(filesToProcess.map(async (fileName) => { return new Promise(async (resolve, reject) => { fileName = program.flags.file.arguments.kind === "ok" ? path.join(process.cwd(), fileName) : fileName; const baseFileName = path .basename(fileName) .split(".") .slice(0, -1) .join("."); const extension = path.extname(fileName); const isValidExtension = extension === ".js" || extension === ".ts"; if (!baseFileName.endsWith("test") || !isValidExtension) { return resolve(null); } results[fileName] = {}; console.log(`Found ${fileName}`); const imported = await Promise.resolve().then(() => __importStar(require(fileName))); for (const functionName of Object.keys(imported)) { if (functionName.startsWith("test")) { if (functionNamesToRun && functionNamesToRun.indexOf(functionName) === -1) continue; const func = imported[functionName]; const isAsync = isAsyncFunction(func); totalTests += 1; if (!onlyFails) console.log(`Running ${functionName}`); try { if (isAsync) { await func(); } else { func(); } results[fileName][functionName] = true; passedTests += 1; } catch (e) { results[fileName][functionName] = false; console.error(chalk.red(`${fileName} ${functionName} failed.`)); console.error(e); } } else if (functionName.startsWith("snapshot")) { if (functionNamesToRun && functionNamesToRun.indexOf(functionName) === -1) continue; const func = imported[functionName]; const isAsync = isAsyncFunction(func); totalTests += 1; if (!onlyFails) console.log(`Running ${functionName}`); try { let computedSnapshot; if (isAsync) { computedSnapshot = await func(); } else { computedSnapshot = func(); } const snapshotFileName = getSnapshotFileName(config, fileName, functionName); let fileContents = computedSnapshot; try { fileContents = (await Promise.resolve().then(() => __importStar(require(path.join(process.cwd(), snapshotFileName)))))[functionName]; } catch (e) { console.log("Creating snapshot for the first time..."); const strToWrite = ` export const ${functionName} = ${JSON.stringify(fileContents, null, 4)}; `.trim(); await fs_1.promises.mkdir(path.dirname(snapshotFileName), { recursive: true }); await fs_1.promises.writeFile(snapshotFileName, strToWrite); } assert_1.default.deepStrictEqual(computedSnapshot, fileContents); results[fileName][functionName] = true; passedTests += 1; } catch (e) { results[fileName][functionName] = false; console.error(chalk.red(`${fileName} ${functionName} failed.`)); console.error(e); } } } resolve(null); }); })); if (filesToProcess.length < files.length) { console.log(`Ran ${chunks} files, starting at ${chunkStart}, out of ${files.length} total. New start should be ${chunkStart + chunks}`); } const endTime = perf_hooks_1.performance.now(); if (program.flags["file"].isPresent) { const formattedResults = []; for (const fileName of Object.keys(results)) { const functions = results[fileName]; for (const functionName of Object.keys(functions)) { formattedResults.push({ functionName, passed: functions[functionName], }); } } if (onlyFails) { viewSingleFileResults(formattedResults.filter((result) => result.passed === false)); } else { viewSingleFileResults(formattedResults); } } else { const formattedResults = Object.entries(results).map(([fileName, functions]) => { let passed = 0; Object.entries(functions).forEach(([functionName, didPass]) => { if (didPass) passed += 1; }); const failed = Object.keys(functions).length - passed; return { fileName, passed, failed, }; }); if (onlyFails) { viewMultipleFileResults(formattedResults.filter((result) => result.failed > 0)); } else { viewMultipleFileResults(formattedResults); } } console.log(`Ran ${totalTests} tests in ${Math.floor(endTime - startTime)}ms. ${passedTests} tests passed, ${totalTests - passedTests} failed`); if (totalTests - passedTests > 0 && !program.flags["clean-exit"].isPresent) { process.exit(1); } } exports.runner = runner; if (require.main === module) { runner(); }