@eeue56/bach
Version:
A simple TypeScript test runner inspired by Pytest.
691 lines (596 loc) • 21.5 kB
text/typescript
import {
bothFlag,
empty,
help,
longFlag,
number,
parse,
parser,
Program,
string,
variableList,
} from "@eeue56/baner";
import assert from "assert";
import glob from "fast-glob";
import { promises as fsPromises } from "fs";
import JSON5 from "json5";
import * as path from "path";
import { performance } from "perf_hooks";
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
type SingleFileResult = {
functionName: string;
passed: boolean;
};
type SingleFileCellSizes = {
functionName: number;
passed: number;
};
type MultipleFileResult = {
fileName: string;
passed: number;
failed: number;
};
type MultipleFileCellSizes = {
fileName: number;
passed: number;
failed: number;
};
const chalk = {
red: function (text: string): string {
return `\x1b[31m${text}\x1b[0m`;
},
green: function (text: string): string {
return `\x1b[32m\x1b[1m${text}\x1b[0m`;
},
};
function widestSingleFileCellSizes(
results: SingleFileResult[]
): SingleFileCellSizes {
const cellSizes: SingleFileCellSizes = {
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: MultipleFileResult[]
): MultipleFileCellSizes {
const cellSizes: MultipleFileCellSizes = {
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 = "┘";
export function centerAndPadding(cellSize: number, text: string): string {
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);
}
function viewSingleFileResult(
result: SingleFileResult,
tableWidth: SingleFileCellSizes
): void {
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: SingleFileResult[]): void {
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: MultipleFileResult,
tableWidth: MultipleFileCellSizes
): void {
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: MultipleFileResult[]): void {
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: any): boolean {
return Object.getPrototypeOf(func).constructor === AsyncFunction;
}
function getSnapshotFileName(
config: any,
fileName: string,
functionName: string
): string {
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: any,
program: Program,
filesToProcess: string[],
functionNamesToRun: string[] | null
): Promise<void> {
let totalSnapshotsUpdated = 0;
await Promise.all(
filesToProcess.map(async (fileName: string): Promise<null> => {
return new Promise(async (resolve, reject): Promise<void> => {
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 import(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 fsPromises.mkdir(path.dirname(snapshotFileName), {
recursive: true,
});
await fsPromises.writeFile(snapshotFileName, strToWrite);
}
resolve(null);
});
})
);
console.log(`Updated ${totalSnapshotsUpdated} snapshots.`);
}
type Results = { [filename: string]: { [functionName: string]: boolean } };
export async function runner(): Promise<any> {
const cliParser = parser([
longFlag("function", "Run a specific function", variableList(string())),
longFlag("file", "Run a specific file", variableList(string())),
longFlag(
"clean-exit",
"Don't use process.exit even if tests fail",
empty()
),
longFlag("only-fails", "Only show the tests that fail", empty()),
longFlag(
"in-chunks",
"Run tests in chunks of N files (suitable for lower memory impact)",
number()
),
longFlag("chunk-start", "Start running chunk at N", number()),
bothFlag(
"u",
"update-snapshots",
"Update the snapshots and exit",
empty()
),
bothFlag("h", "help", "Displays help message", empty()),
]);
const program = parse(cliParser, process.argv);
if (program.flags["h/help"].isPresent) {
console.log(help(cliParser));
return;
}
const onlyFails = program.flags["only-fails"].isPresent;
const functionNamesToRun: string[] | null =
program.flags.function.arguments.kind === "ok"
? (program.flags.function.arguments.value as string[])
: null;
const fileNamesToRun: string[] | null =
program.flags.file.arguments.kind === "ok"
? (program.flags.file.arguments.value as string[])
: null;
console.log("Looking for tsconfig...");
const strConfig = (await fsPromises.readFile("./tsconfig.json")).toString();
const config = JSON5.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 glob(config.include, { absolute: true });
const results: Results = {};
let passedTests = 0;
let totalTests = 0;
const chunks = program.flags["in-chunks"].isPresent
? (program.flags["in-chunks"].arguments as any).value
: files.length;
const chunkStart = program.flags["chunk-start"].isPresent
? (program.flags["chunk-start"].arguments as any).value
: 0;
const startTime = 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: string): Promise<null> => {
return new Promise(async (resolve, reject): Promise<void> => {
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 import(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 import(
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 fsPromises.mkdir(
path.dirname(snapshotFileName),
{ recursive: true }
);
await fsPromises.writeFile(
snapshotFileName,
strToWrite
);
}
assert.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 = performance.now();
if (program.flags["file"].isPresent) {
const formattedResults: SingleFileResult[] = [ ];
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: MultipleFileResult[] = 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);
}
}
if (require.main === module) {
runner();
}