@hyperfrontend/testing/runnerrunner
The runner behind a project's test.config.ts: one node --test run per environment, then the merged coverage report, the threshold gate and the completeness check.
Configuration
A project exports one TestConfig from its test.config.ts. It takes the place of a Jest configuration, and most of its fields have a direct Jest ancestor.
| Field | Meaning |
|---|---|
environments | The runs to make, in order. Each is a TestEnvironment with its own file selection and setup. |
coverageInclude | Globs, relative to the project root, naming every file that must be measured. |
coverageExclude | Globs naming files exempt from measurement, on top of the baseline every project inherits. |
coverageThresholds | Minimum lines, branches and functions percentages, judged on the project as a whole. |
moduleNameMapper | Specifier patterns redirected before normal resolution, with <rootDir> standing for the project root and $1 captures honoured. |
testTimeout | Per-test timeout in milliseconds. Defaults to 30 seconds. |
An environment names itself (the name lands in the output and in lcov.<name>.info), selects files with testMatch and testIgnore, asks for a DOM with dom, and lists the modules to preload in setupFiles, which is what Jest's setupFilesAfterEnv did. withDefaults fills in what a project leaves out: the timeout, and the exclusions in BASELINE_COVERAGE_EXCLUDE, which keep spec files, declaration files and the configuration itself out of what coverage measures.
Run sequence
runProjectTests takes the configuration and a RunContext naming the workspace root, the project root and the coverage directory, and returns a RunOutcome: whether the run passed, the reasons it did not, and the per-file table as lines of text.
Each environment is its own process, started with the hooks preload, the DOM preload when asked for, and each setup module as a further preload, in that order, so a setup module can resolve its imports and assume document exists. The process always collects coverage, with the include and exclude globs and the environment's testIgnore passed as Node's own coverage flags, and writes its raw report through Node's lcov reporter while the package's spec reporter prints to stdout.
An environment that ignores nothing hands its globs to Node; one that does has them expanded first, because Node has no way to exclude files from a --test glob, and an expansion that matches nothing is an error rather than a fallback to Node's own discovery.
The reports are merged rather than read one at a time, because a module evaluated again after jest.resetModules is a separate file to Node and one file to the gate, and because a file exercised by one environment and merely imported by another is judged on both. Thresholds are then compared against the merged totals, which is what a Jest global threshold meant, and every file the include globs name that no environment loaded is reported by path. Node omits such files from its report entirely, so without that check a wholly untested source file would pass a full-coverage gate.
Usage
A driver is a few lines. It decides what to print and what the exit code is; the runner itself writes nothing to the terminal:
import { runProjectTests } from '@hyperfrontend/testing/runner'
import config from './test.config.ts'
const outcome = runProjectTests(config, {
workspaceRoot: import.meta.dirname,
projectRoot: import.meta.dirname,
coverageDir: `${import.meta.dirname}/coverage`,
})
for (const row of outcome.coverageTable) console.log(row)
for (const failure of outcome.failures) console.error(failure)
process.exitCode = outcome.success ? 0 : 1
Setting silent keeps the child processes' output off the terminal. A failure reads as it will be shown: environment "node" reported failures, 93.75% branch coverage does not meet threshold of 95%, or no test loaded these files, so coverage never measured them: followed by one path per line.
it.only is not honoured through the runner, because Node only applies it under --test-only and the runner does not pass that flag; a focused run is a hand-run node --test --test-only.
Design
The run is a child process per environment rather than an in-process call because the preloads act on the process they load into: a DOM installed for one environment would leak into the next, and the resolution hooks cannot be unregistered between runs. The configuration is evaluated by the same Node that runs the specs, through Node's own type stripping, so no loader has to agree with another about what the file means.
The protocol between a driver and the runner is the runProjectTests call itself. How a driver finds the configuration, where it puts coverage, and how it reports are the driver's own decisions, which is why no command-line interface ships in this entry. A driver that itself runs inside a node --test process, or inherits the HFTEST* variables of an outer run, should clear them before calling the runner, or the children it spawns will resolve against the outer run's settings.
API Reference§
ƒ Functions
buildRunPlan( config: TestConfig, environment: TestEnvironment, projectRoot: string, coverageDir: string): RunPlan
Coverage is always collected: every project used to inherit
collectCoverage: true from the shared preset, so making it conditional would quietly weaken the gate.Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The project's configuration, with defaults already applied. |
§environment | TestEnvironment | The environment to run. |
§projectRoot | string | Absolute path to the project root. |
§coverageDir | string | Absolute path to the project's coverage output directory. |
Returns
RunPlanExample
Planning a project's only environment
const resolved = withDefaults(config)
const plan = buildRunPlan(resolved, resolved.environments[0], '/repo/libs/example', '/repo/coverage/libs/example')
spawnSync(process.execPath, plan.argv, { cwd: '/repo/libs/example', stdio: 'inherit' })file: URL Node loads on every platform.Parameters
| Name | Type | Description |
|---|---|---|
§path | string | Absolute path to a module. |
Returns
stringfile: address Node accepts as a specifier.Example
Rendering a preload path Node accepts on every platform
moduleUrl('/repo/libs/example/test.setup.ts') // => 'file:///repo/libs/example/test.setup.ts'
// on Windows the same call turns 'C:\\repo\\test.setup.ts' into 'file:///C:/repo/test.setup.ts', where the bare path would parse as a URL with scheme 'c:'Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The project's configuration. |
§context | RunContext | Where the run happens and where coverage is written. |
Returns
RunOutcomeExample
Running a project and reporting its verdict
const outcome = runProjectTests(config, {
workspaceRoot: '/repo',
projectRoot: '/repo/libs/example',
coverageDir: '/repo/coverage/libs/example',
})
console.log(outcome.coverageTable.join('\n'))
if (!outcome.success) {
console.error(outcome.failures.join('\n'))
process.exitCode = 1
}Node has no way to exclude anything from a
--test glob, so an environment that ignores files cannot express its selection as a pattern and has it expanded here instead. An environment that ignores nothing keeps its globs, which leaves discovery to Node and the argument list readable.Parameters
| Name | Type | Description |
|---|---|---|
§environment | TestEnvironment | The environment being run. |
§projectRoot | string | Absolute path to the project root. |
Returns
string[ ]Example
Expanding globs only when something is ignored
const environment = { name: 'node', testMatch: ['src/**/*.spec.ts'] }
selectSpecFiles(environment, '/repo/libs/example') // => ['src/**/*.spec.ts'], left to Node
selectSpecFiles({ ...environment, testIgnore: ['**/*.browser.spec.ts'] }, '/repo/libs/example')
// => ['src/double.spec.ts', 'src/other.spec.ts'], expanded here because Node cannot exclude files from a globmoduleNameMapper for the resolution hooks, which read it from the environment because they run in a child process.Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The project's configuration. |
Returns
stringExample
Rendering the value of `HF_TEST_MODULE_MAP`
serialiseModuleMap({ ...config, moduleNameMapper: { '^\\./real$': '<rootDir>/src/stub.ts' } })
// => '[["^\\\\./real$","<rootDir>/src/stub.ts"]]'
serialiseModuleMap(config) // => undefined when no mapping is declaredThe hooks read
jest.mock out of a file's source as it loads, and they only read it from files they can recognise. A spec is recognisable by its name; a setup module is not, so the runner has to name them.Parameters
| Name | Type | Description |
|---|---|---|
§environment | TestEnvironment | The environment being run. |
§projectRoot | string | Absolute path to the project root. |
Returns
stringExample
Rendering the value of `HF_TEST_SETUP_FILES`
serialiseSetupFiles({ name: 'node', testMatch: ['src/**/*.spec.ts'], setupFiles: ['test.setup.ts'] }, '/repo/libs/example')
// => '["/repo/libs/example/test.setup.ts"]'Parameters
| Name | Type | Description |
|---|---|---|
§environment | TestEnvironment | The environment being run. |
§projectRoot | string | Absolute path to the project root. |
Returns
string[ ]Example
Anchoring setup modules to the project
setupPaths({ name: 'node', testMatch: ['src/**/*.spec.ts'], setupFiles: ['test.setup.ts'] }, '/repo/libs/example')
// => ['/repo/libs/example/test.setup.ts']withDefaults( config: TestConfig): Required< Pick< TestConfig, "coverageExclude" | "testTimeout">> & TestConfig
Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The configuration as authored. |
Returns
Required< Pick< TestConfig, "coverageExclude" | "testTimeout">> & TestConfigExample
Seeing what a minimal configuration resolves to
const resolved = withDefaults({ environments: [{ name: 'node', testMatch: ['src/**/*.spec.ts'] }], coverageInclude: ['src/**/*.ts'] })
resolved.testTimeout // => 30000
resolved.coverageExclude // => BASELINE_COVERAGE_EXCLUDE, with a project's own exclusions appended after it◆ Types
Node measures lines, branches, and functions. It has no statements metric, so a project's former statements threshold is carried by
lines.jest.config.ts used to hold.A project with both Node-only and DOM-facing suites declares two, each selecting its own files and loading its own setup.