@starci/jest-preset 2.3.6 → 2.4.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/CHANGELOG.md +9 -0
- package/LICENSE +1 -1
- package/README.md +6 -6
- package/{builders.cjs → dist/builders.d.ts} +2 -7
- package/dist/builders.js +15 -0
- package/dist/cache.d.ts +48 -0
- package/dist/cache.js +68 -0
- package/dist/clock.d.ts +20 -0
- package/dist/clock.js +32 -0
- package/dist/entity-manager.d.ts +114 -0
- package/dist/entity-manager.js +138 -0
- package/dist/event-bus.d.ts +52 -0
- package/dist/event-bus.js +106 -0
- package/dist/ids.d.ts +21 -0
- package/dist/ids.js +42 -0
- package/dist/inbox.d.ts +65 -0
- package/dist/inbox.js +173 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +289 -0
- package/dist/lcov.d.ts +16 -0
- package/dist/lcov.js +25 -0
- package/dist/lock.d.ts +37 -0
- package/dist/lock.js +57 -0
- package/dist/matchers.d.ts +27 -0
- package/dist/matchers.js +36 -0
- package/dist/merge.d.ts +14 -0
- package/dist/merge.js +55 -0
- package/dist/mock.d.ts +21 -0
- package/dist/mock.js +52 -0
- package/dist/queue.d.ts +30 -0
- package/dist/queue.js +50 -0
- package/dist/sonar-exclusions.d.ts +7 -0
- package/dist/sonar-exclusions.js +8 -0
- package/dist/unit-metadata.d.ts +23 -0
- package/dist/unit-metadata.js +71 -0
- package/dist/world-runner.d.ts +42 -0
- package/dist/world-runner.js +142 -0
- package/package.json +52 -32
- package/builders.d.ts +0 -2
- package/cache.cjs +0 -67
- package/cache.d.ts +0 -31
- package/clock.cjs +0 -34
- package/clock.d.ts +0 -13
- package/entity-manager.cjs +0 -146
- package/entity-manager.d.ts +0 -103
- package/event-bus.cjs +0 -100
- package/event-bus.d.ts +0 -54
- package/ids.cjs +0 -41
- package/ids.d.ts +0 -13
- package/inbox.cjs +0 -162
- package/inbox.d.ts +0 -58
- package/index.cjs +0 -295
- package/index.d.ts +0 -38
- package/lcov.cjs +0 -25
- package/lock.cjs +0 -56
- package/lock.d.ts +0 -26
- package/matchers.cjs +0 -45
- package/matchers.d.ts +0 -17
- package/merge.cjs +0 -45
- package/mock.cjs +0 -53
- package/mock.d.ts +0 -12
- package/queue.cjs +0 -51
- package/queue.d.ts +0 -26
- package/sonar-exclusions.cjs +0 -8
- package/unit-metadata.cjs +0 -63
- package/world-runner.cjs +0 -142
- /package/{unit-metadata.generated.cjs → dist/unit-metadata.generated.js} +0 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WORLD_RUNNER = exports.UNIT_COMPILER_OPTIONS = exports.COVERAGE_THRESHOLD = exports.COVERAGE_EXCLUDES = exports.FakeIds = exports.fakeIds = exports.builder = exports.fakeInbox = exports.recordingQueueOutbox = exports.recordingEventBus = exports.fakeLock = exports.fakeCache = exports.FakeClock = exports.fakeTransaction = exports.mockEntityManager = exports.createMock = exports.mock = exports.MODULE_NAME_MAPPER = exports.TEST_KIND_FOLDERS = void 0;
|
|
4
|
+
exports.starciJestConfig = starciJestConfig;
|
|
5
|
+
exports.collectCoverageFrom = collectCoverageFrom;
|
|
6
|
+
exports.rootGlob = rootGlob;
|
|
7
|
+
exports.sonarExclusions = sonarExclusions;
|
|
8
|
+
exports.hasCoverageSubjects = hasCoverageSubjects;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const mock_ts_1 = require("./mock.js");
|
|
12
|
+
Object.defineProperty(exports, "mock", { enumerable: true, get: function () { return mock_ts_1.mock; } });
|
|
13
|
+
Object.defineProperty(exports, "createMock", { enumerable: true, get: function () { return mock_ts_1.createMock; } });
|
|
14
|
+
const entity_manager_ts_1 = require("./entity-manager.js");
|
|
15
|
+
Object.defineProperty(exports, "mockEntityManager", { enumerable: true, get: function () { return entity_manager_ts_1.mockEntityManager; } });
|
|
16
|
+
Object.defineProperty(exports, "fakeTransaction", { enumerable: true, get: function () { return entity_manager_ts_1.fakeTransaction; } });
|
|
17
|
+
const clock_ts_1 = require("./clock.js");
|
|
18
|
+
Object.defineProperty(exports, "FakeClock", { enumerable: true, get: function () { return clock_ts_1.FakeClock; } });
|
|
19
|
+
const cache_ts_1 = require("./cache.js");
|
|
20
|
+
Object.defineProperty(exports, "fakeCache", { enumerable: true, get: function () { return cache_ts_1.fakeCache; } });
|
|
21
|
+
const lock_ts_1 = require("./lock.js");
|
|
22
|
+
Object.defineProperty(exports, "fakeLock", { enumerable: true, get: function () { return lock_ts_1.fakeLock; } });
|
|
23
|
+
const event_bus_ts_1 = require("./event-bus.js");
|
|
24
|
+
Object.defineProperty(exports, "recordingEventBus", { enumerable: true, get: function () { return event_bus_ts_1.recordingEventBus; } });
|
|
25
|
+
const queue_ts_1 = require("./queue.js");
|
|
26
|
+
Object.defineProperty(exports, "recordingQueueOutbox", { enumerable: true, get: function () { return queue_ts_1.recordingQueueOutbox; } });
|
|
27
|
+
const inbox_ts_1 = require("./inbox.js");
|
|
28
|
+
Object.defineProperty(exports, "fakeInbox", { enumerable: true, get: function () { return inbox_ts_1.fakeInbox; } });
|
|
29
|
+
const builders_ts_1 = require("./builders.js");
|
|
30
|
+
Object.defineProperty(exports, "builder", { enumerable: true, get: function () { return builders_ts_1.builder; } });
|
|
31
|
+
const ids_ts_1 = require("./ids.js");
|
|
32
|
+
Object.defineProperty(exports, "fakeIds", { enumerable: true, get: function () { return ids_ts_1.fakeIds; } });
|
|
33
|
+
Object.defineProperty(exports, "FakeIds", { enumerable: true, get: function () { return ids_ts_1.FakeIds; } });
|
|
34
|
+
const SONAR_EXCLUSIONS = require("./sonar-exclusions.js");
|
|
35
|
+
/**
|
|
36
|
+
* Coverage is measured where logic runs: every file `<name>.<role>.ts` of a LOGIC role (service, policy, projection, guard, mapper, client, ...)
|
|
37
|
+
* inside the roots the slot manifest marks `coverage: required` (the modules of the back end: domain, integrations, platform, projections).
|
|
38
|
+
* The features (handlers, resolvers, controllers, consumers, processors, steps, sagas, cli commands) are thin by rule (BE_FEATURE_THIN) and are
|
|
39
|
+
* never measured. The scope is NOT written here: `starci app sync` renders it from the manifest (scripts/hfs/coverage-scope.mjs) into the repository's
|
|
40
|
+
* jest.config.js as `starciJestConfig({ coverage: { roots, roles, excludes } })`, and the same derivation renders Sonar's
|
|
41
|
+
* `sonar.coverage.exclusions` and the Codecov paths and components, so the three can never disagree. Every measured file must reach 100 on every metric.
|
|
42
|
+
* The unit run also writes `coverage/lcov.info`: Sonar and Codecov import it.
|
|
43
|
+
*/
|
|
44
|
+
const COVERAGE_EXCLUDES = ["**/dist/**", "**/coverage/**"];
|
|
45
|
+
exports.COVERAGE_EXCLUDES = COVERAGE_EXCLUDES;
|
|
46
|
+
/** The four metrics, each held at 100 per file. */
|
|
47
|
+
const COVERAGE_THRESHOLD = Object.freeze({ lines: 100, branches: 100, functions: 100, statements: 100 });
|
|
48
|
+
exports.COVERAGE_THRESHOLD = COVERAGE_THRESHOLD;
|
|
49
|
+
/** The glob one measured root stands for: every file of a logic role below it. */
|
|
50
|
+
function rootGlob(root, roles) {
|
|
51
|
+
return `${root}**/*.${roles.length === 1 ? roles[0] : `{${roles.join(",")}}`}.ts`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* jest's `collectCoverageFrom` of a coverage scope `{ roots, roles, excludes }`: the glob of every root, then the none directories inside a
|
|
55
|
+
* root (`excludes`, each a glob that ends in `**`) and the generated folders as negations.
|
|
56
|
+
*/
|
|
57
|
+
function collectCoverageFrom(coverage) {
|
|
58
|
+
return [...coverage.roots.map((root) => rootGlob(root, coverage.roles)), ...[...coverage.excludes, ...COVERAGE_EXCLUDES].map((glob) => `!${glob}`)];
|
|
59
|
+
}
|
|
60
|
+
/** One union glob keeps Jest's per-file checks while a scoped map may contain files from only one measured root. */
|
|
61
|
+
function coverageThresholdOf(coverage, root) {
|
|
62
|
+
const rootGlobs = coverage.roots.map((entry) => rootGlob(entry, coverage.roles)).filter((glob) => hasCoverageSubjects(root, glob));
|
|
63
|
+
if (rootGlobs.length === 0)
|
|
64
|
+
return {};
|
|
65
|
+
const glob = rootGlobs.length === 1 ? rootGlobs[0] : `{${rootGlobs.join(",")}}`;
|
|
66
|
+
return { [`./${glob}`]: { ...COVERAGE_THRESHOLD } };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. The list lives in sonar-exclusions.ts, a
|
|
70
|
+
* require-free module whose built file @starci/hfs also ships (packages/hfs/runtime, scripts/hfs/sync-runtime.mjs), so `starci app scaffold`
|
|
71
|
+
* renders the same exclusions before any app dependency is installed.
|
|
72
|
+
*/
|
|
73
|
+
function sonarExclusions() {
|
|
74
|
+
return SONAR_EXCLUSIONS.join(",");
|
|
75
|
+
}
|
|
76
|
+
const TS_TRANSFORM = String.raw `^.+\.ts$`;
|
|
77
|
+
/**
|
|
78
|
+
* ts-jest without a type check (`diagnostics: false`; `isolatedModules: true` comes from @starci/tsconfig, which is where
|
|
79
|
+
* ts-jest 29.4 reads it — the ts-jest option of the same name is deprecated): types are checked once, by `typecheck` / `typecheck:tests`, never per test file.
|
|
80
|
+
* `npm run test:e2e` (and `test:integration`, `test:contract`) runs `npm run typecheck:tests` first so nobody runs a world spec on code that
|
|
81
|
+
* does not type-check.
|
|
82
|
+
*/
|
|
83
|
+
function transform(tsconfig) {
|
|
84
|
+
return { [TS_TRANSFORM]: ["ts-jest", { tsconfig, diagnostics: false }], ...esmDependencyTransform() };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The runtime dependencies of a back end that ship ECMAScript modules only: jest's CommonJS runtime cannot load them, so their
|
|
88
|
+
* files are compiled to CommonJS like the repository's own (ts-jest with `allowJs`) instead of being skipped as node_modules.
|
|
89
|
+
* `jose` is the Supabase access-token verifier of every lite back end (be.integrations.supabase); a lite app keeps full's unit
|
|
90
|
+
* run (ruling:R212/case-1, amended by ruling:R212/case-4), so a spec that imports its guard must load it.
|
|
91
|
+
*/
|
|
92
|
+
const ESM_ONLY_DEPENDENCIES = Object.freeze(["jose"]);
|
|
93
|
+
const ESM_DEPENDENCY_TRANSFORM = String.raw `^.+[\\/]node_modules[\\/](?:${ESM_ONLY_DEPENDENCIES.join("|")})[\\/].+\.js$`;
|
|
94
|
+
function esmDependencyTransform() {
|
|
95
|
+
return { [ESM_DEPENDENCY_TRANSFORM]: ["ts-jest", { tsconfig: { allowJs: true, module: "commonjs", target: "es2022", esModuleInterop: true }, diagnostics: false }] };
|
|
96
|
+
}
|
|
97
|
+
/** node_modules stay untransformed except the ESM-only dependencies above. */
|
|
98
|
+
const TRANSFORM_IGNORE_PATTERNS = Object.freeze([String.raw `[\\/]node_modules[\\/](?!(?:${ESM_ONLY_DEPENDENCIES.join("|")})[\\/])`]);
|
|
99
|
+
/** The unit project transpiles each file independently; helpers stay in tslib, source maps distinguish generated metadata, and type checks run separately. */
|
|
100
|
+
const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: true, importHelpers: true, sourceMap: true });
|
|
101
|
+
exports.UNIT_COMPILER_OPTIONS = UNIT_COMPILER_OPTIONS;
|
|
102
|
+
/** Generated constructor metadata keeps its runtime guards, mapped to a generated-metadata coverage anchor instead of source parameters. */
|
|
103
|
+
function unitTransform() {
|
|
104
|
+
const unitMetadata = require.resolve("./unit-metadata.js");
|
|
105
|
+
return {
|
|
106
|
+
[TS_TRANSFORM]: [unitMetadata, { tsconfig: { ...UNIT_COMPILER_OPTIONS }, diagnostics: false, astTransformers: { after: [unitMetadata] } }],
|
|
107
|
+
...esmDependencyTransform(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The path aliases of a StarCi back end: the same three `paths` the managed `tsconfig.json` declares, so jest resolves what
|
|
112
|
+
* `tsc` resolves. They are part of the preset, not an option: a repository has no other alias.
|
|
113
|
+
*/
|
|
114
|
+
const MODULE_NAME_MAPPER = Object.freeze({
|
|
115
|
+
"^@features/(.*)$": "<rootDir>/src/features/$1",
|
|
116
|
+
"^@modules/(.*)$": "<rootDir>/src/modules/$1",
|
|
117
|
+
"^@tests/(.*)$": "<rootDir>/src/tests/$1",
|
|
118
|
+
});
|
|
119
|
+
exports.MODULE_NAME_MAPPER = MODULE_NAME_MAPPER;
|
|
120
|
+
/** The test folders under `src/tests/` that a project other than `unit` owns (owner test layout 2026-09-30). */
|
|
121
|
+
const TEST_KIND_FOLDERS = Object.freeze(["world", "integration", "e2e", "contract"]);
|
|
122
|
+
exports.TEST_KIND_FOLDERS = TEST_KIND_FOLDERS;
|
|
123
|
+
const underTests = (folder) => String.raw `[\\/]src[\\/]tests[\\/]` + folder + String.raw `[\\/]`;
|
|
124
|
+
/**
|
|
125
|
+
* The whole jest config of a Nest repository: projects `unit` (colocated `<name>.spec.ts`), `integration`
|
|
126
|
+
* (`src/tests/integration/<capability>/<name>.integration-spec.ts`), `e2e` (`src/tests/e2e/<area>/<name>.e2e-spec.ts`) and
|
|
127
|
+
* `contract` (`src/tests/contract/<provider>/<name>.contract-spec.ts`). It takes ONE option, the coverage scope: the repository's `jest.config.js` is a managed file
|
|
128
|
+
* (R05), rendered by `starci app sync` as `module.exports = require("@starci/jest-preset").starciJestConfig({ coverage: { roots, roles, excludes } })`, the scope derived from the slot manifest.
|
|
129
|
+
*
|
|
130
|
+
* The managed scripts select one project each (`test` = unit, `test:integration`, `test:e2e`, `test:contract`); the
|
|
131
|
+
* contract project is never part of `test` or `test:e2e`, and a contract spec skips itself without sandbox config. The
|
|
132
|
+
* test tree compiles against `src/tests/tsconfig.json`, the nearest config of every file under `src/tests/`. The
|
|
133
|
+
* integration, e2e and contract projects share the world's `global-setup.ts`/`global-teardown.ts` (`src/tests/world/`) and
|
|
134
|
+
* run their spec files up to `min(--maxWorkers, slots)` at once, each in a worker process of its own bound to one data slot
|
|
135
|
+
* of the test world (`world-runner.js`; a slot's data is reset when a file boots, so no two files ever share a slot). A declared lite app instead runs its existing integration and contract specs without a world, on jest's stock runner. The unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
|
|
136
|
+
* Coverage is collected from the logic roles of the measured roots only (`coverage` option), with a per-file threshold of 100 on lines, branches, functions and
|
|
137
|
+
* statements: the `test` script runs the unit project with `--coverage`, fails below it and writes `coverage/lcov.info` for Sonar. It uses v8: istanbul instruments the helpers TypeScript emits (`__decorate`, `__param`, `__awaiter`, interop wrappers)
|
|
138
|
+
* as thousands of branches no spec can cover, while v8 measures the real source.
|
|
139
|
+
*/
|
|
140
|
+
/** The runner of the integration, e2e and contract projects (`world-runner.js`): every spec file in a worker process of its own. */
|
|
141
|
+
const WORLD_RUNNER = require.resolve("./world-runner.js");
|
|
142
|
+
exports.WORLD_RUNNER = WORLD_RUNNER;
|
|
143
|
+
/** True when the repository at `root` has the test world's global setup (`src/tests/world/global-setup.ts`). */
|
|
144
|
+
function hasTestWorld(root) {
|
|
145
|
+
return (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "src", "tests", "world", "global-setup.ts"));
|
|
146
|
+
}
|
|
147
|
+
/** The managed config runs in `be/`, one level below the app's edition declaration. */
|
|
148
|
+
function isLiteApp(root) {
|
|
149
|
+
try {
|
|
150
|
+
const file = (0, node_path_1.join)(root, "..", "hfs.json");
|
|
151
|
+
const declaration = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
|
|
152
|
+
return typeof declaration === "object" && declaration !== null && "edition" in declaration && declaration.edition === "lite";
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Every alternative a `{a,b}` pattern spells, braces expanded left to right. */
|
|
159
|
+
function braceVariants(value) {
|
|
160
|
+
const match = /{([^{}]+)}/.exec(value);
|
|
161
|
+
return match ? (match[1] ?? "").split(",").flatMap((part) => braceVariants(`${value.slice(0, match.index)}${part}${value.slice(match.index + match[0].length)}`)) : [value];
|
|
162
|
+
}
|
|
163
|
+
/** The anchored RegExp of one brace-free glob over a posix path: `**` spans directories, `*` and `?` stay inside a segment. */
|
|
164
|
+
function globRegExp(glob) {
|
|
165
|
+
let source = "";
|
|
166
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
167
|
+
const c = glob.charAt(i);
|
|
168
|
+
if (c === "*" && glob.charAt(i + 1) === "*") {
|
|
169
|
+
i += 1;
|
|
170
|
+
if (glob.charAt(i + 1) === "/") {
|
|
171
|
+
i += 1;
|
|
172
|
+
source += "(?:.*/)?";
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
source += ".*";
|
|
176
|
+
}
|
|
177
|
+
else if (c === "*")
|
|
178
|
+
source += "[^/]*";
|
|
179
|
+
else if (c === "?")
|
|
180
|
+
source += "[^/]";
|
|
181
|
+
else
|
|
182
|
+
source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
183
|
+
}
|
|
184
|
+
return new RegExp(`^${source}$`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* True when the repository at `root` holds a file the coverage glob matches: jest refuses a threshold key that matches no covered file,
|
|
188
|
+
* and a back end may have no file of a measured root yet (no projection, no integration), so a root without files gets no key (its files,
|
|
189
|
+
* once added, are measured and held at 100 like every other).
|
|
190
|
+
*/
|
|
191
|
+
function hasCoverageSubjects(root, glob) {
|
|
192
|
+
const base = glob.slice(0, Math.max(0, glob.search(/[*?{]/))).replace(/[^/]*$/, "");
|
|
193
|
+
const patterns = braceVariants(glob).map(globRegExp);
|
|
194
|
+
const walk = (dir) => {
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = (0, node_fs_1.readdirSync)((0, node_path_1.join)(root, dir), { withFileTypes: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return entries.some((entry) => {
|
|
203
|
+
const rel = `${dir}${entry.name}`;
|
|
204
|
+
if (entry.isDirectory())
|
|
205
|
+
return !["node_modules", "dist", "coverage"].includes(entry.name) && walk(`${rel}/`);
|
|
206
|
+
return patterns.some((pattern) => pattern.test(rel));
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
return walk(base);
|
|
210
|
+
}
|
|
211
|
+
const isList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
|
|
212
|
+
/** A property of an unknown value, `undefined` when the value is not an object that has it. */
|
|
213
|
+
const fieldOf = (value, name) => (typeof value === "object" && value !== null && name in value ? Reflect.get(value, name) : undefined);
|
|
214
|
+
/** The scope `{ roots, roles, excludes }` of an options object, or a refusal: the scope is rendered by `starci app sync`, never defaulted. */
|
|
215
|
+
function coverageOf(options) {
|
|
216
|
+
const coverage = fieldOf(options, "coverage");
|
|
217
|
+
const roots = fieldOf(coverage, "roots");
|
|
218
|
+
const roles = fieldOf(coverage, "roles");
|
|
219
|
+
const excludes = fieldOf(coverage, "excludes");
|
|
220
|
+
if (!coverage || !isList(roots) || !isList(roles) || !roles.length || !isList(excludes)) {
|
|
221
|
+
throw new Error("starciJestConfig({ coverage: { roots, roles, excludes } }) needs the coverage scope starci app sync renders from the slot manifest; run \"npx starci app sync --write\"");
|
|
222
|
+
}
|
|
223
|
+
return { roots, roles, excludes };
|
|
224
|
+
}
|
|
225
|
+
/** The whole jest config: projects `unit`, `integration`, `e2e` and `contract`. It takes the coverage scope the managed repository config renders; without it it refuses. */
|
|
226
|
+
function starciJestConfig(options) {
|
|
227
|
+
const coverage = coverageOf(options);
|
|
228
|
+
const root = process.cwd();
|
|
229
|
+
const world = hasTestWorld(root);
|
|
230
|
+
const shared = {
|
|
231
|
+
preset: "ts-jest",
|
|
232
|
+
testEnvironment: "node",
|
|
233
|
+
rootDir: ".",
|
|
234
|
+
roots: ["<rootDir>/src", "<rootDir>/apps"],
|
|
235
|
+
moduleNameMapper: { ...MODULE_NAME_MAPPER },
|
|
236
|
+
transformIgnorePatterns: [...TRANSFORM_IGNORE_PATTERNS],
|
|
237
|
+
};
|
|
238
|
+
const suite = (displayName, suffix) => ({
|
|
239
|
+
...shared,
|
|
240
|
+
transform: transform("src/tests/tsconfig.json"),
|
|
241
|
+
displayName,
|
|
242
|
+
// A glob without the root's absolute path: jest's roots (src, apps) already bound the search, and a root path that holds a dot-named folder (a checkout under `.claude`) turns its backslash separator into a glob escape on Windows, so a path-prefixed pattern would match no file.
|
|
243
|
+
testMatch: [`**/src/tests/${displayName}/**/*.${suffix}.ts`],
|
|
244
|
+
// Full's world gives every spec file its own process and data slot; lite needs neither its runner nor its hooks.
|
|
245
|
+
...(world ? {
|
|
246
|
+
runner: WORLD_RUNNER,
|
|
247
|
+
globalSetup: "<rootDir>/src/tests/world/global-setup.ts",
|
|
248
|
+
globalTeardown: "<rootDir>/src/tests/world/global-teardown.ts",
|
|
249
|
+
} : {}),
|
|
250
|
+
});
|
|
251
|
+
const testProjects = world ? [suite("integration", "integration-spec"), suite("e2e", "e2e-spec"), suite("contract", "contract-spec")] : [];
|
|
252
|
+
if (!world && isLiteApp(root)) {
|
|
253
|
+
for (const name of ["integration", "contract"]) {
|
|
254
|
+
const suffix = `${name}-spec`;
|
|
255
|
+
if (hasCoverageSubjects(root, `src/tests/${name}/**/*.${suffix}.ts`))
|
|
256
|
+
testProjects.push(suite(name, suffix));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// 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
|
|
260
|
+
// writes istanbul JSON: the coverage job merges the shards by source location (merge.ts) and the coverage proof enforces 100 per file on the merged report. The
|
|
261
|
+
// all-files instrumentation of collectCoverageFrom is off there: it instruments an unloaded file with another transform (other functions and branches).
|
|
262
|
+
const shard = process.env.STARCI_COVERAGE_SHARD === "1";
|
|
263
|
+
return {
|
|
264
|
+
coverageProvider: "v8",
|
|
265
|
+
...(shard ? {} : {
|
|
266
|
+
collectCoverageFrom: collectCoverageFrom(coverage),
|
|
267
|
+
// A glob key is applied to every matching file on its own: each measured file must reach 100, not the average.
|
|
268
|
+
coverageThreshold: coverageThresholdOf(coverage, root),
|
|
269
|
+
}),
|
|
270
|
+
coverageDirectory: "coverage",
|
|
271
|
+
// 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.
|
|
272
|
+
coverageReporters: shard ? ["text-summary", "json"] : ["text-summary", "text", [require.resolve("./lcov.js"), {}]],
|
|
273
|
+
projects: [
|
|
274
|
+
{
|
|
275
|
+
...shared,
|
|
276
|
+
transform: unitTransform(),
|
|
277
|
+
displayName: "unit",
|
|
278
|
+
clearMocks: true,
|
|
279
|
+
// The Outcome matchers (`toBeRefused`, `toSucceedWith`) exist in the unit project only.
|
|
280
|
+
setupFilesAfterEnv: [require.resolve("./matchers.js")],
|
|
281
|
+
testMatch: ["**/*.spec.ts"],
|
|
282
|
+
testPathIgnorePatterns: ["/node_modules/", ...TEST_KIND_FOLDERS.map(underTests)],
|
|
283
|
+
},
|
|
284
|
+
// Jest validates every project's world hooks even under --selectProjects unit. An unfinished full scaffold
|
|
285
|
+
// keeps only unit; a lite app's present integration/contract lanes need no test world.
|
|
286
|
+
...testProjects,
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
}
|
package/dist/lcov.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context, ReportNode } from "istanbul-lib-report";
|
|
2
|
+
/** The lcov report of istanbul-reports: built with `{ file, projectRoot }`, driven per file by `onDetail` and as a whole by `execute`. */
|
|
3
|
+
interface LcovReportBase {
|
|
4
|
+
onDetail(node: ReportNode, context: Context): void;
|
|
5
|
+
execute(context: Context): void;
|
|
6
|
+
}
|
|
7
|
+
type LcovReportConstructor = new (options: {
|
|
8
|
+
file: string;
|
|
9
|
+
projectRoot: string;
|
|
10
|
+
}) => LcovReportBase;
|
|
11
|
+
declare const LcovReport: LcovReportConstructor;
|
|
12
|
+
/** LCOV identifies functions by name; append their stable coverage id so same-named callbacks retain separate counts. */
|
|
13
|
+
declare class StarciLcovReport extends LcovReport {
|
|
14
|
+
onDetail(node: ReportNode, context: Context): void;
|
|
15
|
+
}
|
|
16
|
+
export = StarciLcovReport;
|
package/dist/lcov.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const node_module_1 = require("node:module");
|
|
3
|
+
const fromJest = (0, node_module_1.createRequire)(require.resolve("jest"));
|
|
4
|
+
const fromCore = (0, node_module_1.createRequire)(fromJest.resolve("@jest/core"));
|
|
5
|
+
const fromReporters = (0, node_module_1.createRequire)(fromCore.resolve("@jest/reporters"));
|
|
6
|
+
const reports = fromReporters("istanbul-reports");
|
|
7
|
+
const LcovReport = reports.create("lcov").constructor;
|
|
8
|
+
const METADATA_SOURCE = require.resolve("./unit-metadata.generated.js");
|
|
9
|
+
/** LCOV identifies functions by name; append their stable coverage id so same-named callbacks retain separate counts. */
|
|
10
|
+
class StarciLcovReport extends LcovReport {
|
|
11
|
+
onDetail(node, context) {
|
|
12
|
+
if (node.getFileCoverage().path === METADATA_SOURCE)
|
|
13
|
+
return;
|
|
14
|
+
const entries = Object.entries(node.getFileCoverage().fnMap);
|
|
15
|
+
const originals = entries.map(([, meta]) => ({ meta, name: meta.name }));
|
|
16
|
+
try {
|
|
17
|
+
entries.forEach(([id, meta]) => { meta.name = `${meta.name}#${id}`; });
|
|
18
|
+
super.onDetail(node, context);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
originals.forEach(({ meta, name }) => { meta.name = name; });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
module.exports = StarciLcovReport;
|
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fakeLock(clock)` -- a behavioural in-memory double of a lease (cross-replica lock with fencing).
|
|
3
|
+
*
|
|
4
|
+
* `acquire({ name, holder, ttlMs, at? })` grants `{ name, holder, fence }` when nobody holds the name (or the previous
|
|
5
|
+
* ttl ran out on the clock) and answers `null` on contention; the fence grows with every grant of one name.
|
|
6
|
+
* `release({ grant })` lets go only when the grant is still current. A holder acquiring its own live lease renews it.
|
|
7
|
+
* Inspection: `isHeld(name)`, `holderOf(name)`, `fenceOf(name)`. Expiry is driven by the `FakeClock`; a request may carry
|
|
8
|
+
* its own `at`, which wins.
|
|
9
|
+
*/
|
|
10
|
+
/** What a caller asks for when it wants a named lease. */
|
|
11
|
+
export interface FakeLockAcquire {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly holder: string;
|
|
14
|
+
readonly ttlMs: number;
|
|
15
|
+
/** The instant of the request; defaults to the clock of the double. */
|
|
16
|
+
readonly at?: Date;
|
|
17
|
+
}
|
|
18
|
+
/** A granted lease. */
|
|
19
|
+
export interface FakeLockGrant {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly holder: string;
|
|
22
|
+
readonly fence: number;
|
|
23
|
+
}
|
|
24
|
+
/** A behavioural in-memory lease: acquire, contention, release, ttl expiry on the clock, growing fence. */
|
|
25
|
+
export interface FakeLock {
|
|
26
|
+
acquire(params: FakeLockAcquire): Promise<FakeLockGrant | null>;
|
|
27
|
+
release(params: {
|
|
28
|
+
readonly grant: FakeLockGrant;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
isHeld(name: string): boolean;
|
|
31
|
+
holderOf(name: string): string | null;
|
|
32
|
+
fenceOf(name: string): number | null;
|
|
33
|
+
}
|
|
34
|
+
/** The lock double; expiry follows `clock`, so `clock.advance(ms)` frees a lease whose ttl ran out. */
|
|
35
|
+
export declare function fakeLock(clock?: {
|
|
36
|
+
now(): Date;
|
|
37
|
+
}): FakeLock;
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `fakeLock(clock)` -- a behavioural in-memory double of a lease (cross-replica lock with fencing).
|
|
4
|
+
*
|
|
5
|
+
* `acquire({ name, holder, ttlMs, at? })` grants `{ name, holder, fence }` when nobody holds the name (or the previous
|
|
6
|
+
* ttl ran out on the clock) and answers `null` on contention; the fence grows with every grant of one name.
|
|
7
|
+
* `release({ grant })` lets go only when the grant is still current. A holder acquiring its own live lease renews it.
|
|
8
|
+
* Inspection: `isHeld(name)`, `holderOf(name)`, `fenceOf(name)`. Expiry is driven by the `FakeClock`; a request may carry
|
|
9
|
+
* its own `at`, which wins.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.fakeLock = fakeLock;
|
|
13
|
+
/** The lock double; expiry follows `clock`, so `clock.advance(ms)` frees a lease whose ttl ran out. */
|
|
14
|
+
function fakeLock(clock) {
|
|
15
|
+
const state = new Map();
|
|
16
|
+
const fences = new Map();
|
|
17
|
+
const nowOf = (params) => {
|
|
18
|
+
const at = params.at ?? clock?.now();
|
|
19
|
+
if (at === undefined)
|
|
20
|
+
throw new Error("fakeLock: pass the FakeClock, fakeLock(clock), or an `at` on each acquire");
|
|
21
|
+
return at.getTime();
|
|
22
|
+
};
|
|
23
|
+
const liveHold = (name) => {
|
|
24
|
+
const hold = state.get(name);
|
|
25
|
+
if (hold === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (clock !== undefined && clock.now().getTime() >= hold.expiresAt) {
|
|
28
|
+
state.delete(name);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return hold;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
async acquire(params) {
|
|
35
|
+
const at = nowOf(params);
|
|
36
|
+
const held = state.get(params.name);
|
|
37
|
+
const alive = held !== undefined && at < held.expiresAt;
|
|
38
|
+
if (alive && held.holder !== params.holder)
|
|
39
|
+
return null;
|
|
40
|
+
const fence = alive ? held.fence : (fences.get(params.name) ?? 0) + 1;
|
|
41
|
+
fences.set(params.name, fence);
|
|
42
|
+
state.set(params.name, { holder: params.holder, fence, expiresAt: at + params.ttlMs });
|
|
43
|
+
return { name: params.name, holder: params.holder, fence };
|
|
44
|
+
},
|
|
45
|
+
async release({ grant }) {
|
|
46
|
+
const held = state.get(grant.name);
|
|
47
|
+
if (held !== undefined && held.fence === grant.fence && held.holder === grant.holder)
|
|
48
|
+
state.delete(grant.name);
|
|
49
|
+
},
|
|
50
|
+
/** Whether a live lease exists for `name`. */
|
|
51
|
+
isHeld: (name) => liveHold(name) !== undefined,
|
|
52
|
+
/** The holder of the live lease, or null. */
|
|
53
|
+
holderOf: (name) => liveHold(name)?.holder ?? null,
|
|
54
|
+
/** The fence of the latest grant of `name`, or null when it was never granted. */
|
|
55
|
+
fenceOf: (name) => fences.get(name) ?? null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/// <reference types="jest" preserve="true" />
|
|
2
|
+
/**
|
|
3
|
+
* Outcome matchers, registered by the `unit` project's `setupFilesAfterEnv` (nowhere else).
|
|
4
|
+
*
|
|
5
|
+
* They match the platform `Outcome<V, C>` structurally: `{ kind: "ok", value }` or `{ kind: "refused", code, params? }`.
|
|
6
|
+
* `expect(outcome).toBeRefused("STOCK_EXHAUSTED")` (or `{ code, params }`) and `expect(outcome).toSucceedWith(value)`.
|
|
7
|
+
*/
|
|
8
|
+
/** The refusal a matcher expects: a capability code, or a code with the parameters of its display text. */
|
|
9
|
+
export type RefusalReason = string | {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
readonly params?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
declare global {
|
|
14
|
+
namespace jest {
|
|
15
|
+
interface Matchers<R> {
|
|
16
|
+
/** The received `Outcome` is `{ kind: "refused" }` with this code (and these params, when given). */
|
|
17
|
+
toBeRefused(reason: RefusalReason): R;
|
|
18
|
+
/** The received `Outcome` is `{ kind: "ok" }` whose value equals `value` (recursive equality). */
|
|
19
|
+
toSucceedWith(value: unknown): R;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The matchers registered on `expect`. */
|
|
24
|
+
export declare const matchers: {
|
|
25
|
+
toBeRefused(this: jest.MatcherContext, received: unknown, reason: RefusalReason): jest.CustomMatcherResult;
|
|
26
|
+
toSucceedWith(this: jest.MatcherContext, received: unknown, value: unknown): jest.CustomMatcherResult;
|
|
27
|
+
};
|
package/dist/matchers.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/// <reference types="jest" preserve="true" />
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.matchers = void 0;
|
|
5
|
+
const isOutcome = (received) => typeof received === "object" && received !== null && "kind" in received && (received.kind === "ok" || received.kind === "refused");
|
|
6
|
+
const notOutcome = (utils, received) => `expected an Outcome ({ kind: "ok" | "refused" }), received ${utils.printReceived(received)}`;
|
|
7
|
+
/** The matchers registered on `expect`. */
|
|
8
|
+
exports.matchers = {
|
|
9
|
+
toBeRefused(received, reason) {
|
|
10
|
+
if (!isOutcome(received))
|
|
11
|
+
return { pass: false, message: () => notOutcome(this.utils, received) };
|
|
12
|
+
const expected = typeof reason === "string" ? { code: reason } : reason;
|
|
13
|
+
const pass = received.kind === "refused" &&
|
|
14
|
+
received.code === expected.code &&
|
|
15
|
+
(!("params" in expected) || this.equals(received.params, expected.params));
|
|
16
|
+
return {
|
|
17
|
+
pass,
|
|
18
|
+
message: () => pass
|
|
19
|
+
? `expected the outcome not to be refused with ${this.utils.printExpected(expected)}`
|
|
20
|
+
: `expected the outcome to be refused with ${this.utils.printExpected(expected)}, received ${this.utils.printReceived(received)}`,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
toSucceedWith(received, value) {
|
|
24
|
+
if (!isOutcome(received))
|
|
25
|
+
return { pass: false, message: () => notOutcome(this.utils, received) };
|
|
26
|
+
const pass = received.kind === "ok" && this.equals(received.value, value);
|
|
27
|
+
return {
|
|
28
|
+
pass,
|
|
29
|
+
message: () => pass
|
|
30
|
+
? `expected the outcome not to succeed with ${this.utils.printExpected(value)}`
|
|
31
|
+
: `expected the outcome to succeed with ${this.utils.printExpected(value)}, received ${this.utils.printReceived(received)}`,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
if (typeof expect !== "undefined" && typeof expect.extend === "function")
|
|
36
|
+
expect.extend(exports.matchers);
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Every coverage-final.json at or below `target` (a file or a directory), sorted. */
|
|
2
|
+
declare function coverageFilesUnder(target: string): string[];
|
|
3
|
+
/**
|
|
4
|
+
* Merge the shard reports `inputs` (coverage-final.json files or directories holding them) and write `<outDir>/lcov.info` and `<outDir>/coverage-final.json`.
|
|
5
|
+
* Returns { reports, files }; throws when no report is found (an empty merge would read as "nothing to measure").
|
|
6
|
+
*/
|
|
7
|
+
declare function mergeCoverage({ inputs, outDir }: {
|
|
8
|
+
inputs: string[];
|
|
9
|
+
outDir: string;
|
|
10
|
+
}): {
|
|
11
|
+
reports: number;
|
|
12
|
+
files: number;
|
|
13
|
+
};
|
|
14
|
+
export { mergeCoverage, coverageFilesUnder };
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.mergeCoverage = mergeCoverage;
|
|
7
|
+
exports.coverageFilesUnder = coverageFilesUnder;
|
|
8
|
+
// merge.ts - the coverage of a sharded unit run merged into the one report the coverage proof, Codecov and Sonar read (`starci app coverage merge`).
|
|
9
|
+
// Each shard runs with STARCI_COVERAGE_SHARD=1 (starciJestConfig): it measures ONLY the files its tests load (no collectCoverageFrom all-files
|
|
10
|
+
// instrumentation, whose fallback transform gives an unloaded file another function and branch set) and writes istanbul JSON (coverage-final.json).
|
|
11
|
+
// 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
|
|
12
|
+
// merged map is written as lcov through this preset's own lcov reporter (lcov.ts: stable function ids, the generated metadata source left out), exactly
|
|
13
|
+
// 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%).
|
|
14
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const node_module_1 = require("node:module");
|
|
17
|
+
const StarciLcovReport = require("./lcov.js");
|
|
18
|
+
const fromJest = (0, node_module_1.createRequire)(require.resolve("jest"));
|
|
19
|
+
const fromCore = (0, node_module_1.createRequire)(fromJest.resolve("@jest/core"));
|
|
20
|
+
const fromReporters = (0, node_module_1.createRequire)(fromCore.resolve("@jest/reporters"));
|
|
21
|
+
const libCoverage = fromReporters("istanbul-lib-coverage");
|
|
22
|
+
const libReport = fromReporters("istanbul-lib-report");
|
|
23
|
+
/** The folder of a directory entry: `parentPath`, which Node before 20.12 calls `path`. */
|
|
24
|
+
function parentOf(entry) {
|
|
25
|
+
const legacy = Reflect.get(entry, "path");
|
|
26
|
+
return entry.parentPath ?? (typeof legacy === "string" ? legacy : "");
|
|
27
|
+
}
|
|
28
|
+
/** Every coverage-final.json at or below `target` (a file or a directory), sorted. */
|
|
29
|
+
function coverageFilesUnder(target) {
|
|
30
|
+
if (!node_fs_1.default.statSync(target).isDirectory())
|
|
31
|
+
return [target];
|
|
32
|
+
return node_fs_1.default.readdirSync(target, { recursive: true, withFileTypes: true })
|
|
33
|
+
.filter((entry) => entry.isFile() && entry.name === "coverage-final.json")
|
|
34
|
+
.map((entry) => node_path_1.default.join(parentOf(entry), entry.name))
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Merge the shard reports `inputs` (coverage-final.json files or directories holding them) and write `<outDir>/lcov.info` and `<outDir>/coverage-final.json`.
|
|
39
|
+
* Returns { reports, files }; throws when no report is found (an empty merge would read as "nothing to measure").
|
|
40
|
+
*/
|
|
41
|
+
function mergeCoverage({ inputs, outDir }) {
|
|
42
|
+
const reports = inputs.flatMap((input) => coverageFilesUnder(input));
|
|
43
|
+
if (!reports.length)
|
|
44
|
+
throw new Error(`no coverage-final.json under ${inputs.join(", ")}: the unit shards wrote none (run them with STARCI_COVERAGE_SHARD=1)`);
|
|
45
|
+
const map = libCoverage.createCoverageMap({});
|
|
46
|
+
for (const report of reports) {
|
|
47
|
+
const data = JSON.parse(node_fs_1.default.readFileSync(report, "utf8"));
|
|
48
|
+
map.merge(data);
|
|
49
|
+
}
|
|
50
|
+
node_fs_1.default.mkdirSync(outDir, { recursive: true });
|
|
51
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(outDir, "coverage-final.json"), JSON.stringify(map.toJSON()));
|
|
52
|
+
const context = libReport.createContext({ dir: outDir, coverageMap: map });
|
|
53
|
+
new StarciLcovReport({ file: "lcov.info", projectRoot: process.cwd() }).execute(context);
|
|
54
|
+
return { reports: reports.length, files: map.files().length };
|
|
55
|
+
}
|
package/dist/mock.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference types="jest" preserve="true" />
|
|
2
|
+
/**
|
|
3
|
+
* `mock<T>()` -- a typed test double that replaces the `{ ... } as unknown as T` cast.
|
|
4
|
+
*
|
|
5
|
+
* Every property read yields one stable `jest.fn()` (created on first read), so `mock<Repo>().find` is
|
|
6
|
+
* already a mock function, `expect(m.find).toHaveBeenCalledWith(...)` works, and `m.find.mockResolvedValue(x)`
|
|
7
|
+
* type-checks against `Repo["find"]`. Values passed in `overrides` win over the generated functions and may be
|
|
8
|
+
* partial: the return type is still `T`.
|
|
9
|
+
*
|
|
10
|
+
* `createMock` takes the function factory as an argument (called with the property name) so the helper has no hard
|
|
11
|
+
* dependency on a global `jest`; `mock` binds it to `jest.fn`.
|
|
12
|
+
*/
|
|
13
|
+
/** Function members become jest mocks typed against the real signature; everything else stays as declared. */
|
|
14
|
+
export type MockOf<T> = T & {
|
|
15
|
+
[K in keyof T]: T[K] extends (...args: infer A) => infer R ? jest.Mock<R, A> & T[K] : T[K];
|
|
16
|
+
};
|
|
17
|
+
/** The factory behind `mock`, for callers that bring their own function factory (used by the package spec). */
|
|
18
|
+
export declare function createMock(makeFn: (property: string) => unknown): <T extends object>(overrides?: Partial<T>) => MockOf<T>;
|
|
19
|
+
export declare function jestFn(implementation?: (...args: never[]) => unknown): unknown;
|
|
20
|
+
/** A typed test double for `T`. `overrides` may be partial; the result is usable wherever a `T` is required. */
|
|
21
|
+
export declare const mock: <T extends object>(overrides?: Partial<T>) => MockOf<T>;
|
package/dist/mock.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/// <reference types="jest" preserve="true" />
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.mock = void 0;
|
|
5
|
+
exports.createMock = createMock;
|
|
6
|
+
exports.jestFn = jestFn;
|
|
7
|
+
// Probed by promise resolution, jest matchers and serializers: they must read as absent, not as a mock function.
|
|
8
|
+
const ABSENT = new Set(["then", "asymmetricMatch", "$$typeof", "nodeType", "tagName", "toJSON"]);
|
|
9
|
+
/** The factory behind `mock`, for callers that bring their own function factory (used by the package spec). */
|
|
10
|
+
function createMock(makeFn) {
|
|
11
|
+
function mock(overrides) {
|
|
12
|
+
const own = new Map();
|
|
13
|
+
const store = overrides ? { ...overrides } : {};
|
|
14
|
+
return new Proxy(store, {
|
|
15
|
+
get(target, property, receiver) {
|
|
16
|
+
if (Object.hasOwn(target, property))
|
|
17
|
+
return Reflect.get(target, property, receiver);
|
|
18
|
+
if (typeof property === "symbol" || property in Object.prototype)
|
|
19
|
+
return Reflect.get(target, property, receiver);
|
|
20
|
+
if (ABSENT.has(property))
|
|
21
|
+
return undefined;
|
|
22
|
+
if (!own.has(property))
|
|
23
|
+
own.set(property, makeFn(property));
|
|
24
|
+
return own.get(property);
|
|
25
|
+
},
|
|
26
|
+
set(target, property, value) {
|
|
27
|
+
own.delete(property);
|
|
28
|
+
target[property] = value;
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
has(target, property) {
|
|
32
|
+
return Object.hasOwn(target, property) || own.has(property);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return mock;
|
|
37
|
+
}
|
|
38
|
+
// jest-runtime hands every module it loads, this one included, a module-scoped `jest` object; it is not a property
|
|
39
|
+
// of globalThis, so it is read as a free identifier. @jest/globals is the fallback for a runner that does not.
|
|
40
|
+
function jestFn(implementation) {
|
|
41
|
+
if (typeof jest !== "undefined" && typeof jest.fn === "function")
|
|
42
|
+
return jest.fn(implementation);
|
|
43
|
+
try {
|
|
44
|
+
const globals = require("@jest/globals");
|
|
45
|
+
return globals.jest.fn(implementation);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error("@starci/jest-preset mock<T>() needs the jest runtime; call it from a jest test");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** A typed test double for `T`. `overrides` may be partial; the result is usable wherever a `T` is required. */
|
|
52
|
+
exports.mock = createMock(() => jestFn());
|