@hyperfrontend/testing/runner

runner

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.

FieldMeaning
environmentsThe runs to make, in order. Each is a TestEnvironment with its own file selection and setup.
coverageIncludeGlobs, relative to the project root, naming every file that must be measured.
coverageExcludeGlobs naming files exempt from measurement, on top of the baseline every project inherits.
coverageThresholdsMinimum lines, branches and functions percentages, judged on the project as a whole.
moduleNameMapperSpecifier patterns redirected before normal resolution, with <rootDir> standing for the project root and $1 captures honoured.
testTimeoutPer-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

§function

buildRunPlan(config: TestConfig, environment: TestEnvironment, projectRoot: string, coverageDir: string): RunPlan

Builds the argument list for one environment's run.
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
NameTypeDescription
§config
TestConfigThe project's configuration, with defaults already applied.
§environment
TestEnvironmentThe environment to run.
§projectRoot
stringAbsolute path to the project root.
§coverageDir
stringAbsolute path to the project's coverage output directory.
Returns
RunPlan
The invocation plan.
Example

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' })
§function

moduleUrl(path: string): string

Renders an absolute path as the file: URL Node loads on every platform.
Parameters
NameTypeDescription
§path
stringAbsolute path to a module.
Returns
string
A file: 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:'
§function

runProjectTests(config: TestConfig, context: RunContext): RunOutcome

Runs a project's tests, one invocation per declared environment, then verifies that coverage accounted for every file the project asked to measure.
Parameters
NameTypeDescription
§config
TestConfigThe project's configuration.
§context
RunContextWhere the run happens and where coverage is written.
Returns
RunOutcome
Whether the run passed, with the reasons it did not.
Example

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
}
§function

selectSpecFiles(environment: TestEnvironment, projectRoot: string): string[]

Names the files one environment runs.
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
NameTypeDescription
§environment
TestEnvironmentThe environment being run.
§projectRoot
stringAbsolute path to the project root.
Returns
string[]
The globs, or the project-relative paths they resolve to.
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 glob
§function

serialiseModuleMap(config: TestConfig): string

Renders a project's moduleNameMapper for the resolution hooks, which read it from the environment because they run in a child process.
Parameters
NameTypeDescription
§config
TestConfigThe project's configuration.
Returns
string
The serialised pairs, or undefined when the project declares none.
Example

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 declared
§function

serialiseSetupFiles(environment: TestEnvironment, projectRoot: string): string

Renders an environment's setup modules for the resolution hooks.
The 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
NameTypeDescription
§environment
TestEnvironmentThe environment being run.
§projectRoot
stringAbsolute path to the project root.
Returns
string
The serialised paths, or undefined when the environment declares none.
Example

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"]'
§function

setupPaths(environment: TestEnvironment, projectRoot: string): string[]

Resolves an environment's setup modules against the project root.
Parameters
NameTypeDescription
§environment
TestEnvironmentThe environment being run.
§projectRoot
stringAbsolute path to the project root.
Returns
string[]
One absolute path per declared setup module.
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']
§function

withDefaults(config: TestConfig): Required<Pick<TestConfig, "coverageExclude" | "testTimeout">> & TestConfig

Fills in the defaults a project does not state.
Parameters
NameTypeDescription
§config
TestConfigThe configuration as authored.
Returns
Required<Pick<TestConfig, "coverageExclude" | "testTimeout">> & TestConfig
The configuration with baseline exclusions and a timeout applied.
Example

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

§type

CoverageThresholds

Coverage percentages a run must reach to pass.
Node measures lines, branches, and functions. It has no statements metric, so a project's former statements threshold is carried by lines.
§type

RunContext

Where a run happens and what it reports against.
§type

RunOutcome

What a completed run reported.
§type

RunPlan

Everything needed to build one node --test invocation.
§type

TestConfig

A project's test configuration, replacing what jest.config.ts used to hold.
§type

TestEnvironment

One environment a project's tests run under.
A project with both Node-only and DOM-facing suites declares two, each selecting its own files and loading its own setup.

● Variables

§type

BASELINE_COVERAGE_EXCLUDE

Spec files are never part of what coverage measures, and neither is the configuration that selects them. Every project inherits these exclusions so no project has to repeat them.