@sondr3/minitest
Version:
A low-feature, dependency-free and performant test runner inspired by Rust and Deno
194 lines (162 loc) • 5.49 kB
text/typescript
import { promises as fs } from "node:fs";
import { join, parse } from "node:path";
import { performance } from "node:perf_hooks";
import { pathToFileURL } from "node:url";
import { parseCli, printVersionHelp } from "./cli.js";
import { Test } from "./test_fn.js";
import { TestRunner } from "./test_runner.js";
import { color, mapSize } from "./utils.js";
export const TESTS: Array<Test> = [];
export const ignoreDir = (dir: string): boolean => dir === "node_modules" || dir.startsWith(".");
const testExt = (ext: string) => ext === ".js" || ext === ".mjs";
export const testFile = (file: string): boolean => {
const { name, ext } = parse(file);
return testExt(ext) && (name === "test" || name.endsWith(".test") || name.endsWith("_test"));
};
export async function* walkDir(dir: string): AsyncGenerator<string> {
for await (const d of await fs.opendir(dir)) {
const entry = join(dir, d.name);
if (d.isDirectory() && !ignoreDir(d.name)) {
yield* walkDir(entry);
} else if (d.isFile() && testFile(d.name)) {
yield entry;
}
}
}
class Runner {
private readonly quiet;
private readonly failFast?: number;
private readonly filterFn!: (name: string) => boolean;
private readonly filter: boolean = false;
private failures: Array<[string, Error | undefined]> = [];
private tests: Map<string, Array<TestRunner>> = new Map();
private only = false;
private ok = 0;
private failed = 0;
private ignored = 0;
private filtered = 0;
constructor(quiet: boolean, failFast?: number, filter?: (name: string) => boolean) {
this.quiet = quiet;
this.failFast = failFast;
if (filter) {
this.filterFn = filter;
this.filter = true;
}
}
async run(entry: string) {
await this.collect(entry);
this.filterTestsMarkedOnly();
if (this.filter) {
this.filterTests();
}
await this.runTests();
}
report(): void {
const time = performance.now().toFixed(0);
const success = this.failed > 0 ? color("FAILED", "red") : color("ok", "green");
if (this.failed) {
process.stdout.write(`\n\nfailures:\n`);
this.failures
.filter(([, err]) => err)
.forEach(([name, err]) => {
process.stdout.write(`\n---- ${name} message ----\n`);
if (err?.stack) {
process.stdout.write(err.stack);
} else {
process.stdout.write(err?.message ?? "");
}
process.stdout.write(`\n`);
});
process.stdout.write(`\n\nfailures:\n`);
this.failures.forEach(([name]) => process.stdout.write(`\t${name}\n`));
process.stdout.write(`\n`);
}
process.stdout.write(
`\ntest result: ${success}. ${this.ok} passed; ${this.failed} failed; ${this.ignored} ignored; ${this.filtered} filtered out; finished in ${time}ms\n\n`,
);
if (this.failed > 0 || this.only) {
process.exit(1);
}
}
private async collect(entry: string) {
if ((await fs.stat(entry)).isFile()) {
await import(pathToFileURL(entry).toString());
this.addTests(entry);
} else {
for await (const file of walkDir(entry)) {
await import(pathToFileURL(file).toString());
this.addTests(file);
}
}
}
private addTests(entry: string) {
this.tests.set(
entry,
TESTS.splice(0).map((t) => t.toTestRunner()),
);
}
private filterTestsMarkedOnly() {
const onlyTests: Map<string, Array<TestRunner>> = new Map();
for (const [file, tests] of this.tests.entries()) {
if (tests.some((t) => t.only)) {
const onlies = tests.filter((t) => t.only);
this.only = true;
onlyTests.set(file, onlies);
}
}
if (this.only) {
this.tests = onlyTests;
}
}
private async runTests() {
const count = Array.from(this.tests.values()).reduce((prev, it) => prev + it.length, 0);
process.stdout.write(`running ${count} ${count === 1 ? "test" : "tests"}\n`);
for (const [file, xs] of this.tests.entries()) {
if (!this.quiet) {
process.stdout.write(
`running ${xs.length} ${xs.length === 1 ? "test" : "tests"} in ${file}\n`,
);
}
for (const test of xs) {
const res = await test.run();
if (test.ignore) {
this.ignored += 1;
} else if (res) {
this.ok += 1;
} else {
this.failed += 1;
this.failures.push([test.name, test.error]);
}
test.result(this.quiet);
if (this.shouldFail()) {
return;
}
}
}
}
private shouldFail(): boolean {
if (this.failFast === undefined) return false;
return this.failed > this.failFast;
}
private filterTests() {
const filtered: Array<[string, Array<TestRunner>]> = Array.from(this.tests.entries()).flatMap(
([file, xs]) => {
const filtered = xs.filter((t) => this.filterFn(t.name));
return filtered.length > 0 ? [[file, filtered]] : [];
},
);
const tests: Map<string, Array<TestRunner>> = new Map(filtered);
this.filtered = mapSize(this.tests) - mapSize(tests);
this.tests = tests;
}
}
export async function run(argv: Array<string>): Promise<void> {
const { dir, quiet, filter, failFast, version, help } = parseCli(argv);
if (version || help) {
printVersionHelp(version, help);
process.exit(0);
}
const runner = new Runner(quiet, failFast, filter);
await runner.run(dir);
runner.report();
}