@zohodesk/unit-testing-framework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # unit-testing-framework
2
+
3
+ A modular, Jest-based unit testing package designed to plug into existing CLI pipelines. Runs Jest **programmatically** (no shell execution), supports default + consumer config merging, custom reporters, global setup/teardown, and parallel execution.
4
+
5
+ ---
6
+
7
+ ## Folder Structure
8
+
9
+ ```
10
+ unit-testing-framework/
11
+ ├── package.json # ESM package with proper exports
12
+ ├── index.js # Public API entry point
13
+ ├── src/
14
+ │ ├── runner/
15
+ │ │ └── jest-runner.js # Programmatic Jest execution via @jest/core
16
+ │ ├── config/
17
+ │ │ ├── config-loader.js # Config resolution & deep-merge logic
18
+ │ │ └── default-config.js # Framework-level default Jest config
19
+ │ ├── reporters/
20
+ │ │ ├── reporter-handler.js # Reporter resolution (aliases, paths)
21
+ │ │ └── default-reporter.js # Bundled custom Jest reporter
22
+ │ └── environment/
23
+ │ ├── setup.js # Global setup (runs once before suite)
24
+ │ └── teardown.js # Global teardown (runs once after suite)
25
+ ├── examples/
26
+ │ ├── consumer-cli.js # Example integration in existing CLI
27
+ │ └── jest.unit.config.js # Example consumer config file
28
+ ├── .npmignore
29
+ └── README.md
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ npm install unit-testing-framework
38
+ # jest is a required peer dependency
39
+ npm install --save-dev jest
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Quick Start
45
+
46
+ ```js
47
+ import createJestRunner from 'unit-testing-framework';
48
+
49
+ // Run with defaults - auto-discovers jest.unit.config.js in project root
50
+ const results = await createJestRunner();
51
+
52
+ process.exitCode = results.numFailedTests > 0 ? 1 : 0;
53
+ ```
54
+
55
+ ---
56
+
57
+ ## API
58
+
59
+ ### `createJestRunner(options?)`
60
+
61
+ | Option | Type | Default | Description |
62
+ |---|---|---|---|
63
+ | `projectRoot` | `string` | `process.cwd()` | Consumer project root |
64
+ | `configPath` | `string` | (auto-discovered) | Path to consumer config file |
65
+ | `inlineConfig` | `object` | `{}` | Inline Jest config overrides (highest priority) |
66
+ | `coverage` | `boolean` | from config | Enable coverage collection |
67
+ | `testFiles` | `string[]` | all | Specific test file patterns |
68
+ | `verbose` | `boolean` | `true` | Verbose output |
69
+ | `maxWorkers` | `number\|string` | `'50%'` | Worker concurrency |
70
+ | `silent` | `boolean` | `false` | Suppress console output |
71
+ | `watch` | `boolean` | `false` | Watch mode |
72
+
73
+ Returns: `Promise<AggregatedResult>` (Jest results object)
74
+
75
+ ### Named exports
76
+
77
+ ```js
78
+ import {
79
+ createJestRunner,
80
+ loadConfig,
81
+ resolveReporters,
82
+ globalSetup,
83
+ globalTeardown,
84
+ } from 'unit-testing-framework';
85
+ ```
86
+
87
+ Sub-path exports:
88
+ ```js
89
+ import { loadConfig } from 'unit-testing-framework/config';
90
+ import { resolveReporters } from 'unit-testing-framework/reporters';
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Config Priority
96
+
97
+ ```
98
+ inline options > consumer config file > framework defaults
99
+ ```
100
+
101
+ ### Consumer Config File
102
+
103
+ Place one of these in your project root (auto-discovered in order):
104
+
105
+ 1. `jest.unit.config.js`
106
+ 2. `jest.unit.config.mjs`
107
+ 3. `jest.unit.config.json`
108
+
109
+ ```js
110
+ // jest.unit.config.js
111
+ export default {
112
+ roots: ['<rootDir>/tests/unit'],
113
+ maxWorkers: 4,
114
+ collectCoverage: true,
115
+ reporters: ['default', 'framework-default'],
116
+ testTimeout: 60_000,
117
+ };
118
+ ```
119
+
120
+ ### Built-in Reporter Alias
121
+
122
+ Use `'framework-default'` in the `reporters` array to include the bundled reporter.
123
+
124
+ ---
125
+
126
+ ## Consumer CLI Integration
127
+
128
+ ```js
129
+ // In your existing CLI file
130
+ import createJestRunner from 'unit-testing-framework';
131
+
132
+ switch (option) {
133
+ case 'unit-test': {
134
+ const results = await createJestRunner();
135
+ process.exitCode = results.numFailedTests > 0 ? 1 : 0;
136
+ break;
137
+ }
138
+
139
+ case 'unit-test:coverage': {
140
+ const results = await createJestRunner({ coverage: true });
141
+ process.exitCode = results.numFailedTests > 0 ? 1 : 0;
142
+ break;
143
+ }
144
+
145
+ case 'unit-test:watch':
146
+ await createJestRunner({ watch: true });
147
+ break;
148
+ }
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Consumer Project Structure Example
154
+
155
+ ```
156
+ my-app/
157
+ ├── package.json # depends on unit-testing-framework
158
+ ├── jest.unit.config.js # optional overrides
159
+ ├── cli.js # existing CLI with switch/case
160
+ ├── src/
161
+ │ └── utils/
162
+ │ └── math.js
163
+ └── tests/
164
+ └── unit/
165
+ └── math.test.js
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Publishing
171
+
172
+ ```bash
173
+ # 1. Login to npm
174
+ npm login
175
+
176
+ # 2. Verify package contents
177
+ npm pack --dry-run
178
+
179
+ # 3. Publish
180
+ npm publish
181
+
182
+ # For scoped packages:
183
+ npm publish --access public
184
+ ```
185
+
186
+ ### Versioning
187
+
188
+ Follow [semver](https://semver.org/):
189
+
190
+ ```bash
191
+ npm version patch # 1.0.0 -> 1.0.1 (bug fixes)
192
+ npm version minor # 1.0.0 -> 1.1.0 (new features, backward-compatible)
193
+ npm version major # 1.0.0 -> 2.0.0 (breaking changes)
194
+ npm publish
195
+ ```
196
+
197
+ Consumer pins via package.json:
198
+ ```json
199
+ {
200
+ "dependencies": {
201
+ "unit-testing-framework": "^1.0.0"
202
+ }
203
+ }
204
+ ```
205
+
206
+ `^1.0.0` allows auto-upgrade for minor/patch. Use exact pinning (`1.0.0`) for strict control.
207
+
208
+ ---
209
+
210
+ ## License
211
+
212
+ MIT
package/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * unit-testing-framework
3
+ *
4
+ * A modular Jest-based unit testing framework that plugs into
5
+ * existing CLI pipelines via a single exported function.
6
+ *
7
+ * Usage:
8
+ * import createJestRunner from 'unit-testing-framework';
9
+ * const results = await createJestRunner({ configPath: './jest.config.js' });
10
+ */
11
+
12
+ export { default as createJestRunner } from './src/runner/jest-runner.js';
13
+ export { loadConfig } from './src/config/config-loader.js';
14
+ export { resolveReporters } from './src/reporters/reporter-handler.js';
15
+ export { default as globalSetup } from './src/environment/setup.js';
16
+ export { default as globalTeardown } from './src/environment/teardown.js';
17
+
18
+ // Default export for simple consumer usage:
19
+ // import createJestRunner from 'unit-testing-framework';
20
+ export { default } from './src/runner/jest-runner.js';
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@zohodesk/unit-testing-framework",
3
+ "version": "1.0.0",
4
+ "description": "A modular Jest-based unit testing framework",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./config": "./src/config/config-loader.js",
10
+ "./reporters": "./src/reporters/reporter-handler.js",
11
+ "./runner": "./src/runner/jest-runner.js"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "src/**/*.js"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
19
+ "lint": "eslint src/ index.js"
20
+ },
21
+ "keywords": [
22
+ "jest",
23
+ "unit-testing",
24
+ "test-runner",
25
+ "testing-framework",
26
+ "programmatic-jest"
27
+ ],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@jest/core": "29.7.0",
32
+ "@jest/types": "29.6.3",
33
+ "jest-environment-node": "29.7.0"
34
+ },
35
+ "peerDependencies": {
36
+ "jest": "29.7.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "jest": {
40
+ "optional": false
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "jest": "29.7.0"
45
+ }
46
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * config-loader.js
3
+ *
4
+ * Responsible for:
5
+ * 1. Loading the framework default config.
6
+ * 2. Loading optional consumer-level config (file or inline).
7
+ * 3. Deep-merging them with consumer config taking priority.
8
+ *
9
+ * Merge priority (highest → lowest):
10
+ * inline options > consumer config file > framework defaults
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { pathToFileURL } from 'url';
16
+ import { getDefaultConfig } from './default-config.js';
17
+
18
+ /**
19
+ * Deep-merge two plain objects. Arrays are concatenated & de-duped.
20
+ * `source` values override `target` values.
21
+ */
22
+ function deepMerge(target, source) {
23
+ const output = { ...target };
24
+
25
+ for (const key of Object.keys(source)) {
26
+ const srcVal = source[key];
27
+ const tgtVal = target[key];
28
+
29
+ if (srcVal === undefined) continue;
30
+
31
+ if (Array.isArray(srcVal) && Array.isArray(tgtVal)) {
32
+ // Concatenate and de-duplicate
33
+ output[key] = [...new Set([...tgtVal, ...srcVal])];
34
+ } else if (
35
+ srcVal !== null &&
36
+ typeof srcVal === 'object' &&
37
+ !Array.isArray(srcVal) &&
38
+ tgtVal !== null &&
39
+ typeof tgtVal === 'object' &&
40
+ !Array.isArray(tgtVal)
41
+ ) {
42
+ output[key] = deepMerge(tgtVal, srcVal);
43
+ } else {
44
+ output[key] = srcVal;
45
+ }
46
+ }
47
+
48
+ return output;
49
+ }
50
+
51
+ /**
52
+ * Attempt to load a consumer config file.
53
+ * Supports: .js (ESM), .mjs, .json, .ts (if ts-node is available).
54
+ *
55
+ * @param {string} configPath - Absolute path to the config file.
56
+ * @returns {Promise<object>} Loaded configuration or empty object.
57
+ */
58
+ async function loadConfigFile(configPath) {
59
+ if (!fs.existsSync(configPath)) {
60
+ return {};
61
+ }
62
+
63
+ const ext = path.extname(configPath).toLowerCase();
64
+
65
+ if (ext === '.json') {
66
+ const raw = fs.readFileSync(configPath, 'utf-8');
67
+ return JSON.parse(raw);
68
+ }
69
+
70
+ // ESM dynamic import works for .js / .mjs
71
+ const fileUrl = pathToFileURL(configPath).href;
72
+ const mod = await import(fileUrl);
73
+ return mod.default ?? mod;
74
+ }
75
+
76
+ /**
77
+ * Resolve the consumer config file path.
78
+ * Search order:
79
+ * 1. Explicit `configPath` option
80
+ * 2. `jest.unit.config.js` in project root
81
+ * 3. `jest.unit.config.mjs` in project root
82
+ * 4. `jest.unit.config.json` in project root
83
+ *
84
+ * @param {string} projectRoot
85
+ * @param {string} [explicitPath]
86
+ * @returns {string|null}
87
+ */
88
+ function resolveConfigFilePath(projectRoot, explicitPath) {
89
+ if (explicitPath) {
90
+ const abs = path.isAbsolute(explicitPath)
91
+ ? explicitPath
92
+ : path.resolve(projectRoot, explicitPath);
93
+ return fs.existsSync(abs) ? abs : null;
94
+ }
95
+
96
+ const candidates = [
97
+ 'jest.unit.config.js',
98
+ 'jest.unit.config.mjs',
99
+ 'jest.unit.config.json',
100
+ ];
101
+
102
+ for (const name of candidates) {
103
+ const full = path.resolve(projectRoot, name);
104
+ if (fs.existsSync(full)) return full;
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Load and merge configuration.
112
+ *
113
+ * @param {object} options
114
+ * @param {string} options.projectRoot - Consumer project root (default: process.cwd()).
115
+ * @param {string} [options.configPath] - Explicit path to consumer config.
116
+ * @param {object} [options.inlineConfig] - Inline overrides (highest priority).
117
+ * @returns {Promise<import('@jest/types').Config.InitialOptions>}
118
+ */
119
+ export async function loadConfig({
120
+ projectRoot = process.cwd(),
121
+ configPath,
122
+ inlineConfig = {},
123
+ } = {}) {
124
+ // 1. Framework defaults
125
+ const defaults = getDefaultConfig(projectRoot);
126
+
127
+ // 2. Consumer config file
128
+ const resolvedPath = resolveConfigFilePath(projectRoot, configPath);
129
+ const consumerFileConfig = resolvedPath
130
+ ? await loadConfigFile(resolvedPath)
131
+ : {};
132
+
133
+ // 3. Merge: defaults ← consumer file ← inline
134
+ const merged = deepMerge(deepMerge(defaults, consumerFileConfig), inlineConfig);
135
+
136
+ return merged;
137
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * default-config.js
3
+ *
4
+ * Framework-level default Jest configuration.
5
+ * Consumer projects can override any of these values
6
+ * via their own config file or inline options.
7
+ */
8
+
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ /**
16
+ * Returns the default Jest configuration object.
17
+ * Paths are resolved relative to the consumer's project root
18
+ * (passed at runtime), NOT relative to this package.
19
+ *
20
+ * @param {string} projectRoot - Absolute path to the consumer project root.
21
+ * @returns {import('@jest/types').Config.InitialOptions}
22
+ */
23
+ export function getDefaultConfig(projectRoot) {
24
+ return {
25
+ // --------------- Roots & File Discovery ---------------
26
+ roots: [path.resolve(projectRoot, 'tests')],
27
+ testMatch: [
28
+ '**/*.test.js',
29
+ '**/*.test.mjs',
30
+ '**/*.test.ts',
31
+ '**/*.spec.js',
32
+ '**/*.spec.mjs',
33
+ '**/*.spec.ts',
34
+ ],
35
+ testPathIgnorePatterns: ['/node_modules/', '/dist/', '/build/'],
36
+
37
+ // --------------- Environment ---------------
38
+ testEnvironment: 'node',
39
+
40
+ // --------------- Transform ---------------
41
+ // ESM-native – no transform needed for plain JS.
42
+ // Consumer can add ts-jest / babel-jest as needed.
43
+ transform: {},
44
+
45
+ // --------------- Coverage ---------------
46
+ collectCoverage: false,
47
+ coverageDirectory: path.resolve(projectRoot, 'coverage'),
48
+ coverageReporters: ['text', 'lcov', 'clover'],
49
+ coveragePathIgnorePatterns: ['/node_modules/', '/tests/'],
50
+
51
+ // --------------- Reporters ---------------
52
+ reporters: ['default'],
53
+
54
+ // --------------- Parallelism & Performance ---------------
55
+ maxWorkers: '50%', // Use half of available CPUs
56
+ maxConcurrency: 5,
57
+
58
+ // --------------- Timeouts ---------------
59
+ testTimeout: 30_000, // 30 seconds per test
60
+
61
+ // --------------- Setup Files ---------------
62
+ // Injects `jest` into globalThis so ESM test files don't need
63
+ // `import { jest } from '@jest/globals'` manually.
64
+ setupFiles: [path.resolve(__dirname, '..', 'environment', 'globals-inject.js')],
65
+
66
+ // --------------- Global Setup / Teardown ---------------
67
+ globalSetup: path.resolve(__dirname, '..', 'environment', 'setup.js'),
68
+ globalTeardown: path.resolve(__dirname, '..', 'environment', 'teardown.js'),
69
+
70
+ // --------------- Module Resolution ---------------
71
+ moduleFileExtensions: ['js', 'mjs', 'ts', 'json', 'node'],
72
+
73
+ // --------------- Misc ---------------
74
+ verbose: true,
75
+ passWithNoTests: true,
76
+ };
77
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * globals-inject.js
3
+ *
4
+ * Framework-level setup file that injects Jest globals (like `jest`)
5
+ * into `globalThis` so that ESM test files can use `jest.fn()`,
6
+ * `jest.mock()`, etc. without needing an explicit import.
7
+ *
8
+ * This file is referenced in the default config's `setupFiles` array.
9
+ * It runs in the test worker context after the environment is
10
+ * installed, making `@jest/globals` available.
11
+ */
12
+
13
+ import { jest } from '@jest/globals';
14
+
15
+ globalThis.jest = jest;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Global Setup
3
+ *
4
+ * Runs once before the entire test suite.
5
+ * Jest calls this module's default export.
6
+ *
7
+ * Consumer projects can override by setting `globalSetup`
8
+ * in their jest.unit.config.js to their own setup file.
9
+ */
10
+
11
+ export default async function globalSetup(_globalConfig) {
12
+ // Store start timestamp for reporting
13
+ process.env.__UTL_START_TIME__ = Date.now().toString();
14
+
15
+ console.log('[unit-testing-framework] Global setup complete.');
16
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Global Teardown
3
+ *
4
+ * Runs once after the entire test suite completes.
5
+ * Jest calls this module's default export.
6
+ *
7
+ * Consumer projects can override by setting `globalTeardown`
8
+ * in their jest.unit.config.js to their own teardown file.
9
+ */
10
+
11
+ export default async function globalTeardown(_globalConfig) {
12
+ const startTime = Number(process.env.__UTL_START_TIME__ || Date.now());
13
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
14
+
15
+ console.log(`[unit-testing-framework] Global teardown complete. Total time: ${elapsed}s`);
16
+
17
+ // Cleanup environment variable
18
+ delete process.env.__UTL_START_TIME__;
19
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * default-reporter.js
3
+ *
4
+ * A lightweight custom Jest reporter that can be bundled with the framework.
5
+ * Consumer projects can use this as-is or supply their own reporters.
6
+ *
7
+ * Jest Reporter interface (class-based):
8
+ * - onRunStart(results, options)
9
+ * - onTestStart(test)
10
+ * - onTestResult(test, testResult, results)
11
+ * - onRunComplete(contexts, results)
12
+ */
13
+
14
+ export default class DefaultReporter {
15
+ constructor(globalConfig, _reporterOptions) {
16
+ this.globalConfig = globalConfig;
17
+ this._startTime = 0;
18
+ }
19
+
20
+ onRunStart(_results, _options) {
21
+ this._startTime = Date.now();
22
+ console.log('\n╔══════════════════════════════════════════╗');
23
+ console.log('║ Unit Testing Framework – Test Run ║');
24
+ console.log('╚══════════════════════════════════════════╝\n');
25
+ }
26
+
27
+ onTestStart(test) {
28
+ console.log(` ▶ Running: ${test.path}`);
29
+ }
30
+
31
+ onTestResult(_test, testResult, _aggregatedResults) {
32
+ const { numPassingTests, numFailingTests, numPendingTests } = testResult;
33
+ const icon = numFailingTests > 0 ? '✖' : '✔';
34
+ console.log(
35
+ ` ${icon} Passed: ${numPassingTests} Failed: ${numFailingTests} Skipped: ${numPendingTests}`
36
+ );
37
+ }
38
+
39
+ onRunComplete(_contexts, results) {
40
+ const elapsed = ((Date.now() - this._startTime) / 1000).toFixed(2);
41
+ const { numPassedTests, numFailedTests, numTotalTests } = results;
42
+
43
+ console.log('\n──────────────────────────────────────────');
44
+ console.log(` Total: ${numTotalTests}`);
45
+ console.log(` Passed: ${numPassedTests}`);
46
+ console.log(` Failed: ${numFailedTests}`);
47
+ console.log(` Time: ${elapsed}s`);
48
+ console.log('──────────────────────────────────────────\n');
49
+ }
50
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * reporter-handler.js
3
+ *
4
+ * Resolves the final reporters array for Jest configuration.
5
+ *
6
+ * Supports:
7
+ * - 'default' → Jest built-in default reporter
8
+ * - 'framework-default' → This package's DefaultReporter
9
+ * - Absolute paths → Consumer-supplied reporter modules
10
+ * - [reporterPath, options] tuples
11
+ */
12
+
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ const BUILTIN_ALIASES = {
20
+ 'framework-default': path.resolve(__dirname, 'default-reporter.js'),
21
+ };
22
+
23
+ /**
24
+ * Resolve a single reporter entry.
25
+ *
26
+ * @param {string | [string, object]} entry
27
+ * @param {string} projectRoot
28
+ * @returns {string | [string, object]}
29
+ */
30
+ function resolveEntry(entry, projectRoot) {
31
+ const isArray = Array.isArray(entry);
32
+ const name = isArray ? entry[0] : entry;
33
+ const opts = isArray ? entry[1] : undefined;
34
+
35
+ // Check built-in aliases
36
+ if (BUILTIN_ALIASES[name]) {
37
+ const resolved = BUILTIN_ALIASES[name];
38
+ return opts ? [resolved, opts] : resolved;
39
+ }
40
+
41
+ // 'default' and package names are passed through as-is
42
+ if (name === 'default' || !name.startsWith('.')) {
43
+ return entry;
44
+ }
45
+
46
+ // Relative paths → resolve from consumer project root
47
+ const abs = path.resolve(projectRoot, name);
48
+ return opts ? [abs, opts] : abs;
49
+ }
50
+
51
+ /**
52
+ * Resolve the reporters array for final Jest config.
53
+ *
54
+ * @param {Array<string | [string, object]>} reporters - Raw reporters from merged config.
55
+ * @param {string} projectRoot - Consumer project root.
56
+ * @returns {Array<string | [string, object]>}
57
+ */
58
+ export function resolveReporters(reporters = ['default'], projectRoot = process.cwd()) {
59
+ return reporters.map((entry) => resolveEntry(entry, projectRoot));
60
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * jest-runner.js
3
+ *
4
+ * Core module that runs Jest programmatically via @jest/core's runCLI.
5
+ *
6
+ * Exported as default from the package's index.js so consumers do:
7
+ * import createJestRunner from 'unit-testing-framework';
8
+ * await createJestRunner({ projectRoot: process.cwd() });
9
+ */
10
+
11
+ import path from 'path';
12
+ import { loadConfig } from '../config/config-loader.js';
13
+ import { resolveReporters } from '../reporters/reporter-handler.js';
14
+
15
+ /**
16
+ * @typedef {object} RunnerOptions
17
+ * @property {string} [projectRoot] - Absolute path to the consumer project (default: cwd).
18
+ * @property {string} [configPath] - Path to consumer jest.unit.config.{js,mjs,json}.
19
+ * @property {object} [inlineConfig] - Inline Jest config overrides (highest priority).
20
+ * @property {boolean} [coverage] - Enable coverage collection.
21
+ * @property {string[]} [testFiles] - Specific test file patterns to run.
22
+ * @property {boolean} [verbose] - Verbose output.
23
+ * @property {number|string} [maxWorkers] - Worker concurrency (e.g. '50%' or 4).
24
+ * @property {boolean} [silent] - Suppress console output from tests.
25
+ * @property {boolean} [watch] - Run in watch mode.
26
+ */
27
+
28
+ /**
29
+ * Create and execute a Jest test run.
30
+ *
31
+ * @param {RunnerOptions} [options={}]
32
+ * @returns {Promise<import('@jest/core').AggregatedResult>} Jest aggregated results.
33
+ */
34
+ export default async function createJestRunner(options = {}) {
35
+ const {
36
+ projectRoot = process.cwd(),
37
+ configPath,
38
+ inlineConfig = {},
39
+ coverage,
40
+ testFiles,
41
+ verbose,
42
+ maxWorkers,
43
+ silent,
44
+ watch = false,
45
+ } = options;
46
+
47
+ // ── 1. Load & merge configuration ──────────────────────────
48
+ const mergedConfig = await loadConfig({
49
+ projectRoot,
50
+ configPath,
51
+ inlineConfig,
52
+ });
53
+
54
+ // ── 2. Apply CLI-level overrides ───────────────────────────
55
+ if (coverage !== undefined) mergedConfig.collectCoverage = coverage;
56
+ if (verbose !== undefined) mergedConfig.verbose = verbose;
57
+ if (maxWorkers !== undefined) mergedConfig.maxWorkers = maxWorkers;
58
+ if (silent !== undefined) mergedConfig.silent = silent;
59
+
60
+ // ── 3. Resolve reporters ───────────────────────────────────
61
+ mergedConfig.reporters = resolveReporters(mergedConfig.reporters, projectRoot);
62
+
63
+ // ── 4. Build argv for runCLI ───────────────────────────────
64
+ // runCLI expects a yargs-like argv object.
65
+ const argv = buildArgv(mergedConfig, { testFiles, watch, projectRoot });
66
+
67
+ // ── 5. Run Jest programmatically ───────────────────────────
68
+ // Lazy-import to keep startup fast; @jest/core is heavy.
69
+ const { runCLI } = await import('@jest/core');
70
+
71
+ const { results } = await runCLI(argv, [projectRoot]);
72
+
73
+ // ── 6. Return results for the caller to inspect ────────────
74
+ return results;
75
+ }
76
+
77
+ /**
78
+ * Build a yargs-compatible argv object that runCLI understands.
79
+ *
80
+ * @param {object} config - Merged Jest config.
81
+ * @param {object} extra
82
+ * @param {string[]} [extra.testFiles]
83
+ * @param {boolean} [extra.watch]
84
+ * @param {string} extra.projectRoot
85
+ * @returns {object}
86
+ */
87
+ function buildArgv(config, { testFiles, watch, projectRoot }) {
88
+ const argv = {
89
+ // Serialise the config so Jest uses our merged config
90
+ // instead of searching for jest.config.* files.
91
+ config: JSON.stringify(config),
92
+
93
+ // Project root for resolution
94
+ rootDir: projectRoot,
95
+
96
+ // Flags
97
+ watch: watch,
98
+ watchAll: false,
99
+ ci: process.env.CI === 'true',
100
+
101
+ // Pass-through values that CLI users might expect
102
+ verbose: config.verbose ?? true,
103
+ collectCoverage: config.collectCoverage ?? false,
104
+ passWithNoTests: config.passWithNoTests ?? true,
105
+ maxWorkers: config.maxWorkers ?? '50%',
106
+ silent: config.silent ?? false,
107
+
108
+ // Do not search for config files automatically
109
+ _: testFiles ?? [],
110
+ $0: 'unit-testing-framework',
111
+ };
112
+
113
+ return argv;
114
+ }