@starci/jest-preset 2.3.0 → 2.3.2
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/CHANGELOG.md +8 -0
- package/README.md +8 -5
- package/index.cjs +18 -18
- package/index.d.ts +1 -1
- package/lcov.cjs +25 -0
- package/merge.cjs +45 -0
- package/package.json +7 -3
- package/unit-metadata.cjs +63 -0
- package/unit-metadata.generated.cjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.2 - 2026-10-18
|
|
4
|
+
- Added: shard mode `STARCI_COVERAGE_SHARD=1` (only the loaded files are measured, no per-file threshold, istanbul JSON) and `@starci/jest-preset/merge` (istanbul-lib-coverage merge by source location, lcov through the preset reporter): the coverage job of the sharded managed CI.
|
|
5
|
+
|
|
6
|
+
## 2.3.1 - 2026-10-18
|
|
7
|
+
|
|
8
|
+
- Fixed: coverage excludes only compiler-emitted constructor `design:paramtypes` metadata guards injected by the preset transform; application source is never excluded, and the generated anchor is omitted from LCOV.
|
|
9
|
+
- Changed: unit transforms use isolated transpilation instead of a per-file type program. A bundled ts-jest after-transformer preserves constructor metadata guards while mapping only those generated calls to a dedicated generated-metadata coverage anchor, keeping per-file 100 coverage reachable without hiding source branches. `importHelpers` stays enabled; type checking remains a separate gate. The bundled LCOV reporter gives repeated callback names unique identities so the app coverage gate can verify function totals.
|
|
10
|
+
|
|
3
11
|
## 2.3.0 - 2026-10-16
|
|
4
12
|
|
|
5
13
|
- Added: a declared lite app (the edition of the `hfs.json` above its `be/`) runs its existing integration and contract specs without a test world: those projects exist only when their spec files do, and neither the world runner nor the world global setup and teardown is attached to a lite run.
|
package/README.md
CHANGED
|
@@ -36,11 +36,14 @@ and the exclusions. A repository has no other jest setting to tune.
|
|
|
36
36
|
`{ lines: 100, branches: 100, functions: 100, statements: 100 }` on each of those globs — a glob key applies to each file on its own, so every file of a
|
|
37
37
|
`ruleParams.be.logicRoles` role inside a measured slot must reach 100 on every metric (an average never passes). The
|
|
38
38
|
managed `test` script runs the unit project with `--coverage` and fails below it. Coverage is measured by v8, which counts the
|
|
39
|
-
real source. The unit project overlays `isolatedModules:
|
|
40
|
-
|
|
41
|
-
`typeof
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
real source. The unit project overlays `isolatedModules: true`, `importHelpers: true` and `sourceMap: true` on the repository tsconfig, so ts-jest
|
|
40
|
+
transpiles each file without building a type program. `unit-metadata.cjs`, a ts-jest wrapper and after-transformer, maps
|
|
41
|
+
only synthetic `typeof value === "function" ? value : Object` guards inside TypeScript's generated `__metadata("design:paramtypes", ...)` calls to the dedicated `unit-metadata.generated.cjs` coverage anchor. Their runtime guards remain unchanged, including
|
|
42
|
+
the fallback to `Object` for an undefined or non-class dependency; generated branches are not attributed to source parameters.
|
|
43
|
+
The wrapper restores that second source after ts-jest emits its map. The anchor ignores generated guards only and is omitted from LCOV. Application source calls and guards remain measured. Source maps are enabled even when the build config disables them.
|
|
44
|
+
Source conditionals, including branches on the same constructor line, still count toward per-file 100 coverage. Decorator helpers
|
|
45
|
+
come from `tslib`, which the repository lists in its devDependencies (`UNIT_COMPILER_OPTIONS`). Reporters are `text-summary`,
|
|
46
|
+
`text` and the bundled LCOV reporter (which gives same-named callbacks unique function identities): the run writes `coverage/lcov.info` under the repository (be/) root.
|
|
44
47
|
- **Sonar and Codecov import that lcov, scoped to the same measured files.** `starci app sync` renders `sonar.coverage.exclusions` (the complement of the measured files: SonarQube has no coverage inclusions) and the `codecov.yml`
|
|
45
48
|
status paths and components from the one derivation (`scripts/hfs/coverage-scope.mjs`), so jest, Sonar and Codecov measure the same files;
|
|
46
49
|
the Sonar gate holds coverage at 100 overall, on new code and per file. `sonarExclusions()` still renders `sonar.exclusions`
|
package/index.cjs
CHANGED
|
@@ -72,21 +72,15 @@ function esmDependencyTransform() {
|
|
|
72
72
|
/** node_modules stay untransformed except the ESM-only dependencies above. */
|
|
73
73
|
const TRANSFORM_IGNORE_PATTERNS = Object.freeze([String.raw`[\\/]node_modules[\\/](?!(?:${ESM_ONLY_DEPENDENCIES.join("|")})[\\/])`])
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
* `tsconfig.json` it finds under `rootDir`). They exist so that per-file 100 coverage is reachable for a decorated service:
|
|
78
|
-
* - `isolatedModules: false`: with it on, TypeScript cannot tell whether an imported name is a class, and emits a
|
|
79
|
-
* `typeof (_a = typeof Dep !== "undefined" && Dep) === "function" ? _a : Object` guard per class-typed constructor parameter for
|
|
80
|
-
* `design:paramtypes`; the `Object` arm can never run, so the branch stays uncovered. With the full language service the
|
|
81
|
-
* metadata is emitted as plain `Dep`.
|
|
82
|
-
* - `importHelpers: true`: the decorator helpers (`__decorate`, `__param`, `__metadata`) come from `tslib`, not inlined per file
|
|
83
|
-
* with branches of their own. The repository lists `tslib` in its devDependencies.
|
|
84
|
-
* Integration, e2e and contract keep the repository setting: they measure nothing.
|
|
85
|
-
*/
|
|
86
|
-
const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: false, importHelpers: true })
|
|
75
|
+
/** The unit project transpiles each file independently; helpers stay in tslib, source maps distinguish generated metadata, and type checks run separately. */
|
|
76
|
+
const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: true, importHelpers: true, sourceMap: true })
|
|
87
77
|
|
|
78
|
+
/** Generated constructor metadata keeps its runtime guards, mapped to a generated-metadata coverage anchor instead of source parameters. */
|
|
88
79
|
function unitTransform() {
|
|
89
|
-
|
|
80
|
+
const transforms = transform({ ...UNIT_COMPILER_OPTIONS })
|
|
81
|
+
transforms[TS_TRANSFORM][0] = require.resolve("./unit-metadata.cjs")
|
|
82
|
+
transforms[TS_TRANSFORM][1].astTransformers = { after: [require.resolve("./unit-metadata.cjs")] }
|
|
83
|
+
return transforms
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
/**
|
|
@@ -229,15 +223,21 @@ function starciJestConfig(options) {
|
|
|
229
223
|
if (hasCoverageSubjects(root, `src/tests/${name}/**/*.${suffix}.ts`)) testProjects.push(suite(name, suffix))
|
|
230
224
|
}
|
|
231
225
|
}
|
|
226
|
+
// A shard of the managed CI (STARCI_COVERAGE_SHARD=1, one of npm test -- --shard=i/N) measures only the files its tests load, without the per-file threshold, and
|
|
227
|
+
// writes istanbul JSON: the coverage job merges the shards by source location (merge.cjs) and the coverage proof enforces 100 per file on the merged report. The
|
|
228
|
+
// all-files instrumentation of collectCoverageFrom is off there: it instruments an unloaded file with another transform (other functions and branches).
|
|
229
|
+
const shard = process.env.STARCI_COVERAGE_SHARD === "1"
|
|
232
230
|
return {
|
|
233
231
|
testTimeout: 120_000,
|
|
234
232
|
coverageProvider: "v8",
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
233
|
+
...(shard ? {} : {
|
|
234
|
+
collectCoverageFrom: collectCoverageFrom(coverage),
|
|
235
|
+
// A glob key is applied to every matching file on its own: each measured file must reach 100, not the average.
|
|
236
|
+
coverageThreshold: Object.fromEntries(coverage.roots.map((root) => rootGlob(root, coverage.roles)).filter((glob) => hasCoverageSubjects(process.cwd(), glob)).map((glob) => [`./${glob}`, { ...COVERAGE_THRESHOLD }])),
|
|
237
|
+
}),
|
|
238
238
|
coverageDirectory: "coverage",
|
|
239
|
-
// lcov is what Sonar imports (coverage/lcov.info); text-summary and text are what the developer reads.
|
|
240
|
-
coverageReporters: ["text-summary", "text", "lcov"],
|
|
239
|
+
// lcov is what Sonar imports (coverage/lcov.info); text-summary and text are what the developer reads; a shard writes the json the merge reads.
|
|
240
|
+
coverageReporters: shard ? ["text-summary", "json"] : ["text-summary", "text", [require.resolve("./lcov.cjs"), {}]],
|
|
241
241
|
projects: [
|
|
242
242
|
{
|
|
243
243
|
...shared,
|
package/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export declare function hasCoverageSubjects(root: string, glob: string): boolean
|
|
|
32
32
|
export declare function sonarExclusions(): string
|
|
33
33
|
export declare const COVERAGE_EXCLUDES: string[]
|
|
34
34
|
/** The compiler options the unit project overlays on the repository tsconfig so per-file 100 coverage of a decorated service is reachable. */
|
|
35
|
-
export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules:
|
|
35
|
+
export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules: true; importHelpers: true; sourceMap: true }>
|
|
36
36
|
/** The runner of the integration, e2e and contract projects (`world-runner.cjs`): every spec file in a worker process of its own. */
|
|
37
37
|
export declare const WORLD_RUNNER: string
|
|
38
38
|
export declare const COVERAGE_THRESHOLD: Readonly<{ lines: 100; branches: 100; functions: 100; statements: 100 }>
|
package/lcov.cjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const { createRequire } = require("node:module")
|
|
4
|
+
const fromJest = createRequire(require.resolve("jest"))
|
|
5
|
+
const fromCore = createRequire(fromJest.resolve("@jest/core"))
|
|
6
|
+
const fromReporters = createRequire(fromCore.resolve("@jest/reporters"))
|
|
7
|
+
const LcovReport = fromReporters("istanbul-reports").create("lcov").constructor
|
|
8
|
+
const METADATA_SOURCE = require.resolve("./unit-metadata.generated.cjs")
|
|
9
|
+
|
|
10
|
+
/** LCOV identifies functions by name; append their stable coverage id so same-named callbacks retain separate counts. */
|
|
11
|
+
class StarciLcovReport extends LcovReport {
|
|
12
|
+
onDetail(node, context) {
|
|
13
|
+
if (node.getFileCoverage().path === METADATA_SOURCE) return
|
|
14
|
+
const entries = Object.entries(node.getFileCoverage().fnMap)
|
|
15
|
+
const names = entries.map(([, meta]) => meta.name)
|
|
16
|
+
try {
|
|
17
|
+
entries.forEach(([id, meta]) => { meta.name = `${meta.name}#${id}` })
|
|
18
|
+
super.onDetail(node, context)
|
|
19
|
+
} finally {
|
|
20
|
+
entries.forEach(([, meta], index) => { meta.name = names[index] })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = StarciLcovReport
|
package/merge.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
// merge.cjs - the coverage of a sharded unit run merged into the one report the coverage proof, Codecov and Sonar read (`starci app coverage merge`).
|
|
4
|
+
// Each shard runs with STARCI_COVERAGE_SHARD=1 (starciJestConfig): it measures ONLY the files its tests load (no collectCoverageFrom all-files
|
|
5
|
+
// instrumentation, whose fallback transform gives an unloaded file another function and branch set) and writes istanbul JSON (coverage-final.json).
|
|
6
|
+
// The maps of one file come from the same v8 transform in every shard; istanbul-lib-coverage merges them by source location (FileCoverage.merge), and the
|
|
7
|
+
// merged map is written as lcov through this preset's own lcov reporter (lcov.cjs: stable function ids, the generated metadata source left out), exactly
|
|
8
|
+
// as an unsharded run writes it. A measured file no shard loaded is absent from the merged map: the coverage proof refuses it (no lcov record, 0%).
|
|
9
|
+
const fs = require("node:fs")
|
|
10
|
+
const path = require("node:path")
|
|
11
|
+
const { createRequire } = require("node:module")
|
|
12
|
+
|
|
13
|
+
const fromJest = createRequire(require.resolve("jest"))
|
|
14
|
+
const fromCore = createRequire(fromJest.resolve("@jest/core"))
|
|
15
|
+
const fromReporters = createRequire(fromCore.resolve("@jest/reporters"))
|
|
16
|
+
const libCoverage = fromReporters("istanbul-lib-coverage")
|
|
17
|
+
const libReport = fromReporters("istanbul-lib-report")
|
|
18
|
+
const StarciLcovReport = require("./lcov.cjs")
|
|
19
|
+
|
|
20
|
+
/** Every coverage-final.json at or below `target` (a file or a directory), sorted. */
|
|
21
|
+
function coverageFilesUnder(target) {
|
|
22
|
+
if (!fs.statSync(target).isDirectory()) return [target]
|
|
23
|
+
return fs.readdirSync(target, { recursive: true, withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isFile() && entry.name === "coverage-final.json")
|
|
25
|
+
.map((entry) => path.join(entry.parentPath ?? entry.path, entry.name))
|
|
26
|
+
.sort()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge the shard reports `inputs` (coverage-final.json files or directories holding them) and write `<outDir>/lcov.info` and `<outDir>/coverage-final.json`.
|
|
31
|
+
* Returns { reports, files }; throws when no report is found (an empty merge would read as "nothing to measure").
|
|
32
|
+
*/
|
|
33
|
+
function mergeCoverage({ inputs, outDir }) {
|
|
34
|
+
const reports = inputs.flatMap((input) => coverageFilesUnder(input))
|
|
35
|
+
if (!reports.length) throw new Error(`no coverage-final.json under ${inputs.join(", ")}: the unit shards wrote none (run them with STARCI_COVERAGE_SHARD=1)`)
|
|
36
|
+
const map = libCoverage.createCoverageMap({})
|
|
37
|
+
for (const report of reports) map.merge(JSON.parse(fs.readFileSync(report, "utf8")))
|
|
38
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
39
|
+
fs.writeFileSync(path.join(outDir, "coverage-final.json"), JSON.stringify(map.toJSON()))
|
|
40
|
+
const context = libReport.createContext({ dir: outDir, coverageMap: map })
|
|
41
|
+
new StarciLcovReport({ file: "lcov.info", projectRoot: process.cwd() }).execute(context)
|
|
42
|
+
return { reports: reports.length, files: map.files().length }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { mergeCoverage, coverageFilesUnder }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/jest-preset",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"description": "The StarCi jest config for Nest repositories (unit, integration, e2e and contract projects, ts-jest with diagnostics off, per-file 100 coverage of *.service.ts only) and the one shared unit-test double kit: typed mock<T>(), mockEntityManager(), fakeTransaction(), fakeCache(), fakeLock(), recordingEventBus(), recordingQueueOutbox(), fakeInbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"./builders": {
|
|
55
55
|
"types": "./builders.d.ts",
|
|
56
56
|
"default": "./builders.cjs"
|
|
57
|
+
},
|
|
58
|
+
"./merge": {
|
|
59
|
+
"default": "./merge.cjs"
|
|
57
60
|
}
|
|
58
61
|
},
|
|
59
62
|
"files": [
|
|
@@ -68,11 +71,11 @@
|
|
|
68
71
|
"access": "public"
|
|
69
72
|
},
|
|
70
73
|
"peerDependencies": {
|
|
74
|
+
"@jest/globals": ">=29",
|
|
71
75
|
"@types/jest": ">=29",
|
|
72
76
|
"jest": ">=29",
|
|
73
77
|
"ts-jest": ">=29",
|
|
74
|
-
"typeorm": ">=0.3"
|
|
75
|
-
"@jest/globals": ">=29"
|
|
78
|
+
"typeorm": ">=0.3"
|
|
76
79
|
},
|
|
77
80
|
"scripts": {
|
|
78
81
|
"test": "node --test \"*.spec.mjs\""
|
|
@@ -90,6 +93,7 @@
|
|
|
90
93
|
"jest": "29.7.0",
|
|
91
94
|
"jest-util": "29.7.0",
|
|
92
95
|
"ts-jest": "29.4.12",
|
|
96
|
+
"tslib": "2.8.1",
|
|
93
97
|
"typescript": "5.9.3"
|
|
94
98
|
}
|
|
95
99
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const METADATA_SOURCE = require.resolve("./unit-metadata.generated.cjs")
|
|
4
|
+
const METADATA_TEXT = require("node:fs").readFileSync(METADATA_SOURCE, "utf8")
|
|
5
|
+
|
|
6
|
+
/** Only synthetic conditional guards inside compiler-emitted parameter metadata map to the dedicated ignore anchor. */
|
|
7
|
+
function factory(compiler) {
|
|
8
|
+
const ts = compiler.configSet.compilerModule
|
|
9
|
+
const source = ts.createSourceMapSource(METADATA_SOURCE, METADATA_TEXT)
|
|
10
|
+
return (context) => {
|
|
11
|
+
const visit = (node) => {
|
|
12
|
+
if (isGeneratedMetadata(ts, node)) {
|
|
13
|
+
const guard = (child) => {
|
|
14
|
+
if (isGeneratedGuard(ts, child)) {
|
|
15
|
+
ts.setSourceMapRange(child, { pos: METADATA_TEXT.indexOf("\n") + 1, end: METADATA_TEXT.length - 1, source })
|
|
16
|
+
ts.setEmitFlags(child, ts.getEmitFlags(child) | ts.EmitFlags.NoNestedSourceMaps)
|
|
17
|
+
return child
|
|
18
|
+
}
|
|
19
|
+
return ts.visitEachChild(child, guard, context)
|
|
20
|
+
}
|
|
21
|
+
return ts.visitEachChild(node, guard, context)
|
|
22
|
+
}
|
|
23
|
+
return ts.visitEachChild(node, visit, context)
|
|
24
|
+
}
|
|
25
|
+
return (file) => ts.visitNode(file, visit)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Only TypeScript's synthetic __metadata("design:paramtypes", ...) call is eligible; source calls remain measured. */
|
|
30
|
+
function isGeneratedMetadata(ts, node) {
|
|
31
|
+
return node.pos < 0 && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "__metadata" && node.arguments.length === 2 && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === "design:paramtypes"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A metadata exclusion requires TypeScript's synthetic `typeof value === "function" ? value : Object` guard shape. */
|
|
35
|
+
function isGeneratedGuard(ts, node) {
|
|
36
|
+
if (node.pos >= 0 || !ts.isConditionalExpression(node)) return false
|
|
37
|
+
const condition = node.condition
|
|
38
|
+
return ts.isBinaryExpression(condition) && condition.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken && ts.isTypeOfExpression(condition.left) && ts.isStringLiteral(condition.right) && condition.right.text === "function" && ts.isIdentifier(node.whenFalse) && node.whenFalse.text === "Object"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** ts-jest normalizes map sources to the input file; restore the generated metadata anchor before Jest reads the map. */
|
|
42
|
+
function restoreMetadataSource(result) {
|
|
43
|
+
const prefix = "sourceMappingURL=data:application/json;charset=utf-8;base64,"
|
|
44
|
+
const offset = result.code.lastIndexOf(prefix)
|
|
45
|
+
if (offset < 0) return result
|
|
46
|
+
const start = offset + prefix.length
|
|
47
|
+
const map = JSON.parse(Buffer.from(result.code.slice(start), "base64").toString("utf8"))
|
|
48
|
+
if (map.sourcesContent?.length !== 2 || map.sourcesContent[1] !== METADATA_TEXT) return result
|
|
49
|
+
map.sources = [map.sources[0], METADATA_SOURCE]
|
|
50
|
+
return { ...result, code: result.code.slice(0, start) + Buffer.from(JSON.stringify(map)).toString("base64") }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Unit transforms delegate compilation and caching to ts-jest; the after-transformer changes metadata mappings only. */
|
|
54
|
+
function createTransformer(options) {
|
|
55
|
+
const delegate = require("ts-jest").default.createTransformer(options)
|
|
56
|
+
return {
|
|
57
|
+
getCacheKey: (...args) => delegate.getCacheKey(...args) + ":starci-unit-metadata-5",
|
|
58
|
+
process: (...args) => restoreMetadataSource(delegate.process(...args)),
|
|
59
|
+
processAsync: async (...args) => restoreMetadataSource(await delegate.processAsync(...args)),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { name: "starci-unit-metadata", version: 5, factory, createTransformer }
|