@japa/runner
Version:
A simple yet powerful testing framework for Node.js
514 lines (501 loc) • 15.9 kB
JavaScript
import {
dot,
github,
ndjson,
spec
} from "./chunk-U3BSXCEH.js";
import {
Group,
Refiner,
Test,
TestContext,
colors
} from "./chunk-PCBL2VZP.js";
// src/debug.ts
import { debuglog } from "util";
var debug_default = debuglog("japa:runner");
// src/validator.ts
var Validator = class {
/**
* Ensures the japa is configured. Otherwise raises an exception
*/
ensureIsConfigured(config) {
if (!config) {
throw new Error(
`Cannot run tests. Make sure to call "configure" method before the "run" method`
);
}
}
/**
* Ensures the japa is in planning phase
*/
ensureIsInPlanningPhase(phase) {
if (phase !== "planning") {
throw new Error(
`Cannot import japa test file directly. It must be imported by calling the "japa.run" method`
);
}
}
/**
* Ensures the suites filter uses a subset of the user configured suites.
*/
validateSuitesFilter(config) {
if (!config.filters.suites || !config.filters.suites.length) {
return;
}
if (!("suites" in config) || !config.suites.length) {
throw new Error(`Cannot apply suites filter. You have not configured any test suites`);
}
const suites = config.suites.map(({ name }) => name);
const unknownSuites = config.filters.suites.filter((suite) => !suites.includes(suite));
if (unknownSuites.length) {
throw new Error(`Cannot apply suites filter. "${unknownSuites[0]}" suite is not configured`);
}
}
/**
* Ensure there are unique suites
*/
validateSuitesForUniqueness(config) {
if (!("suites" in config)) {
return;
}
const suites = /* @__PURE__ */ new Set();
config.suites.forEach(({ name }) => {
if (suites.has(name)) {
throw new Error(`Duplicate suite "${name}"`);
}
suites.add(name);
});
suites.clear();
}
/**
* Ensure the activated reporters are in the list of defined
* reporters
*/
validateActivatedReporters(config) {
const reportersList = config.reporters.list.map(({ name }) => name);
const unknownReporters = config.reporters.activated.filter(
(name) => !reportersList.includes(name)
);
if (unknownReporters.length) {
throw new Error(
`Invalid reporter "${unknownReporters[0]}". Make sure to register it first inside the "reporters.list" array`
);
}
}
};
var validator_default = new Validator();
// src/files_manager.ts
import slash from "slash";
import fastGlob from "fast-glob";
import { pathToFileURL } from "url";
var FILE_SUFFIX_EXPRESSION = /(\.spec|\.test)?\.[js|ts|jsx|tsx|mjs|mts|cjs|cts]+$/;
var FilesManager = class {
/**
* Returns a collection of files from the user defined
* glob or the implementation function
*/
async getFiles(cwd, files, excludes) {
if (Array.isArray(files) || typeof files === "string") {
const testFiles = await fastGlob(files, {
absolute: true,
onlyFiles: true,
cwd,
ignore: excludes
});
return testFiles.map((file) => pathToFileURL(file));
}
return await files();
}
/**
* Applies file name filter on a collection of file
* URLs
*/
grep(files, filters) {
return files.filter((file) => {
const filename = slash(file.pathname);
const filenameWithoutTestSuffix = filename.replace(FILE_SUFFIX_EXPRESSION, "");
return !!filters.find((filter) => {
if (filename.endsWith(filter)) {
return true;
}
const filterSegments = filter.split("/").reverse();
const fileSegments = filenameWithoutTestSuffix.split("/").reverse();
return filterSegments.every((segment, index) => {
return fileSegments[index] && (segment === "*" || fileSegments[index].endsWith(segment));
});
});
});
}
};
// src/planner.ts
var Planner = class {
#config;
#fileManager = new FilesManager();
constructor(config) {
validator_default.validateActivatedReporters(config);
validator_default.validateSuitesFilter(config);
validator_default.validateSuitesForUniqueness(config);
this.#config = config;
}
/**
* Returns a list of reporters based upon the activated
* reporters list.
*/
#getActivatedReporters() {
return this.#config.reporters.activated.map((activated) => {
return this.#config.reporters.list.find(({ name }) => activated === name);
});
}
/**
* A generic method to collect files from the user defined
* files glob and apply the files filter
*/
async #collectFiles(files) {
let filesURLs = await this.#fileManager.getFiles(this.#config.cwd, files, this.#config.exclude);
if (this.#config.filters.files && this.#config.filters.files.length) {
filesURLs = this.#fileManager.grep(filesURLs, this.#config.filters.files);
}
return filesURLs;
}
/**
* Returns a collection of suites and their associated
* test files by applying all the filters
*/
async #getSuites() {
let suites = [];
let suitesFilters = this.#config.filters.suites || [];
if ("files" in this.#config) {
suites.push({
name: "default",
files: this.#config.files,
timeout: this.#config.timeout,
retries: this.#config.retries,
filesURLs: await this.#collectFiles(this.#config.files)
});
}
if ("suites" in this.#config) {
for (let suite of this.#config.suites) {
if (!suitesFilters.length || suitesFilters.includes(suite.name)) {
suites.push({
...suite,
filesURLs: await this.#collectFiles(suite.files)
});
}
}
}
return suites;
}
/**
* Returns a list of filters to the passed to the refiner
*/
#getRefinerFilters() {
return Object.keys(this.#config.filters).reduce(
(result, layer) => {
if (layer === "tests" || layer === "tags" || layer === "groups") {
result.push({ layer, filters: this.#config.filters[layer] });
}
return result;
},
[]
);
}
/**
* Creates a plan for running the tests
*/
async plan() {
const suites = await this.#getSuites();
const reporters = this.#getActivatedReporters();
const refinerFilters = this.#getRefinerFilters();
return {
reporters,
suites,
refinerFilters,
config: this.#config
};
}
};
// src/hooks.ts
import Hooks from "@poppinss/hooks";
var GlobalHooks = class {
#hooks = new Hooks();
#setupRunner;
#teardownRunner;
/**
* Apply hooks from the config
*/
apply(config) {
config.setup.forEach((hook) => this.#hooks.add("setup", hook));
config.teardown.forEach((hook) => this.#hooks.add("teardown", hook));
}
/**
* Perform setup
*/
async setup(runner) {
this.#setupRunner = this.#hooks.runner("setup");
this.#teardownRunner = this.#hooks.runner("teardown");
await this.#setupRunner.run(runner);
}
/**
* Perform cleanup
*/
async teardown(error, runner) {
if (this.#setupRunner) {
await this.#setupRunner.cleanup(error, runner);
}
if (this.#teardownRunner) {
if (!error) {
await this.#teardownRunner.run(runner);
}
await this.#teardownRunner.cleanup(error, runner);
}
}
};
// src/cli_parser.ts
import getopts from "getopts";
var OPTIONS = {
string: ["tests", "groups", "tags", "files", "timeout", "retries", "reporters", "bailLayer"],
boolean: ["help", "matchAll", "failed", "bail"],
alias: {
forceExit: "force-exit",
matchAll: "match-all",
bailLayer: "bail-layer",
help: "h"
}
};
var GET_HELP = () => `
${colors.yellow("@japa/runner v2.3.0")}
${colors.green("--tests")} ${colors.dim("Filter tests by the test title")}
${colors.green("--groups")} ${colors.dim("Filter tests by the group title")}
${colors.green("--tags")} ${colors.dim("Filter tests by tags")}
${colors.green("--match-all")} ${colors.dim("Run tests that matches all the supplied tags")}
${colors.green("--files")} ${colors.dim("Filter tests by the file name")}
${colors.green("--force-exit")} ${colors.dim("Forcefully exit the process")}
${colors.green("--timeout")} ${colors.dim("Define default timeout for all tests")}
${colors.green("--retries")} ${colors.dim("Define default retries for all tests")}
${colors.green("--reporters")} ${colors.dim("Activate one or more test reporters")}
${colors.green("--failed")} ${colors.dim("Run tests failed during the last run")}
${colors.green("--bail")} ${colors.dim("Exit early when a test fails")}
${colors.green("--bail-layer")} ${colors.dim('Specify at which layer to enable the bail mode. Can be "group" or "suite"')}
${colors.green("-h, --help")} ${colors.dim("View help")}
${colors.yellow("Examples:")}
${colors.dim('node bin/test.js --tags="@github"')}
${colors.dim('node bin/test.js --tags="~@github"')}
${colors.dim('node bin/test.js --tags="@github,@slow,@integration" --match-all')}
${colors.dim("node bin/test.js --force-exit")}
${colors.dim('node bin/test.js --files="user"')}
${colors.dim('node bin/test.js --files="functional/user"')}
${colors.dim('node bin/test.js --files="unit/user"')}
${colors.dim("node bin/test.js --failed")}
${colors.dim("node bin/test.js --bail")}
${colors.dim("node bin/test.js --bail=group")}
${colors.yellow("Notes:")}
- When groups and tests filters are applied together. We will first filter the
tests by group title and then apply the tests filter.
- The timeout defined on test object takes precedence over the ${colors.green("--timeout")} flag.
- The retries defined on test object takes precedence over the ${colors.green("--retries")} flag.
- The ${colors.green("--files")} flag checks for the file names ending with the filter substring.
- The ${colors.green("--tags")} filter runs tests that has one or more of the supplied tags.
- You can use the ${colors.green("--match-all")} flag to run tests that has all the supplied tags.
`;
var CliParser = class {
/**
* Parses command-line arguments
*/
parse(argv) {
return getopts(argv, OPTIONS);
}
/**
* Returns the help string
*/
getHelp() {
return GET_HELP();
}
};
// src/config_manager.ts
var NOOP = () => {
};
var DEFAULTS = {
files: [],
timeout: 2e3,
retries: 0,
forceExit: false,
plugins: [],
reporters: {
activated: ["spec"].concat(process.env.GITHUB_ACTIONS === "true" ? ["github"] : []),
list: [spec(), ndjson(), dot(), github()]
},
importer: (filePath) => import(filePath.href),
configureSuite: () => {
}
};
var ConfigManager = class {
#config;
#cliArgs;
constructor(config, cliArgs) {
this.#config = config;
this.#cliArgs = cliArgs;
}
/**
* Processes a CLI argument and converts it to an
* array of strings
*/
#processAsArray(value, splitByComma) {
return Array.isArray(value) ? value : splitByComma ? value.split(",").map((item) => item.trim()) : [value];
}
/**
* Returns a copy of filters based upon the CLI
* arguments.
*/
#getCLIFilters() {
const filters = {};
if (this.#cliArgs.tags) {
filters.tags = this.#processAsArray(this.#cliArgs.tags, true);
}
if (this.#cliArgs.tests) {
filters.tests = this.#processAsArray(this.#cliArgs.tests, false);
}
if (this.#cliArgs.files) {
filters.files = this.#processAsArray(this.#cliArgs.files, true);
}
if (this.#cliArgs.groups) {
filters.groups = this.#processAsArray(this.#cliArgs.groups, false);
}
if (this.#cliArgs._ && this.#cliArgs._.length) {
filters.suites = this.#processAsArray(this.#cliArgs._, true);
}
return filters;
}
/**
* Returns the timeout from the CLI args
*/
#getCLITimeout() {
if (this.#cliArgs.timeout) {
const value = Number(this.#cliArgs.timeout);
if (!Number.isNaN(value)) {
return value;
}
}
}
/**
* Returns the retries from the CLI args
*/
#getCLIRetries() {
if (this.#cliArgs.retries) {
const value = Number(this.#cliArgs.retries);
if (!Number.isNaN(value)) {
return value;
}
}
}
/**
* Returns the forceExit property from the CLI args
*/
#getCLIForceExit() {
if (this.#cliArgs.forceExit) {
return true;
}
}
/**
* Returns reporters selected using the commandline
* --reporter flag
*/
#getCLIReporters() {
if (this.#cliArgs.reporters) {
return this.#processAsArray(this.#cliArgs.reporters, true);
}
}
/**
* Hydrates the config with user defined options and the
* command-line flags.
*/
hydrate() {
const cliFilters = this.#getCLIFilters();
const cliRetries = this.#getCLIRetries();
const cliTimeout = this.#getCLITimeout();
const cliReporters = this.#getCLIReporters();
const cliForceExit = this.#getCLIForceExit();
debug_default("filters applied using CLI flags %O", cliFilters);
const baseConfig = {
cwd: this.#config.cwd ?? process.cwd(),
exclude: this.#config.exclude || ["node_modules/**", ".git/**", "coverage/**"],
filters: Object.assign({}, this.#config.filters ?? {}, cliFilters),
importer: this.#config.importer ?? DEFAULTS.importer,
refiner: this.#config.refiner ?? new Refiner(),
retries: cliRetries ?? this.#config.retries ?? DEFAULTS.retries,
timeout: cliTimeout ?? this.#config.timeout ?? DEFAULTS.timeout,
plugins: this.#config.plugins ?? DEFAULTS.plugins,
forceExit: cliForceExit ?? this.#config.forceExit ?? DEFAULTS.forceExit,
reporters: this.#config.reporters ? {
activated: this.#config.reporters.activated,
list: this.#config.reporters.list || DEFAULTS.reporters.list
} : DEFAULTS.reporters,
configureSuite: this.#config.configureSuite ?? DEFAULTS.configureSuite,
setup: this.#config.setup || [],
teardown: this.#config.teardown || []
};
if (cliReporters) {
baseConfig.reporters.activated = cliReporters;
}
if ("files" in this.#config) {
return {
files: this.#config.files,
...baseConfig
};
}
return {
suites: this.#config.suites.map((suite) => {
return {
name: suite.name,
files: suite.files,
timeout: cliTimeout ?? suite.timeout ?? baseConfig.timeout,
retries: cliRetries ?? suite.retries ?? baseConfig.retries,
configure: suite.configure || NOOP
};
}),
...baseConfig
};
}
};
// src/create_test.ts
var contextBuilder = (testInstance) => new TestContext(testInstance);
function createTest(title, emitter, refiner, options) {
const testInstance = new Test(title, contextBuilder, emitter, refiner, options.group);
testInstance.options.meta.suite = options.suite;
testInstance.options.meta.group = options.group;
testInstance.options.meta.fileName = options.file;
if (options.timeout !== void 0) {
testInstance.timeout(options.timeout);
}
if (options.retries !== void 0) {
testInstance.retry(options.retries);
}
if (options.group) {
options.group.add(testInstance);
} else if (options.suite) {
options.suite.add(testInstance);
}
return testInstance;
}
function createTestGroup(title, emitter, refiner, options) {
if (options.group) {
throw new Error("Nested groups are not supported by Japa");
}
const group = new Group(title, emitter, refiner);
group.options.meta.suite = options.suite;
group.options.meta.fileName = options.file;
if (options.suite) {
options.suite.add(group);
}
return group;
}
export {
debug_default,
validator_default,
Planner,
GlobalHooks,
CliParser,
ConfigManager,
createTest,
createTestGroup
};