@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/queue.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One recorded job write. */
|
|
2
|
+
export interface QueueWrite {
|
|
3
|
+
readonly queue: string;
|
|
4
|
+
readonly payload: object;
|
|
5
|
+
readonly tx: unknown;
|
|
6
|
+
/** Whether `tx` was the view a `fakeTransaction` body received. */
|
|
7
|
+
readonly inTransaction: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** A `QueueOutbox` double that records the jobs a typed producer writes. */
|
|
10
|
+
export interface RecordingQueueOutbox {
|
|
11
|
+
write(tx: unknown, queue: string, payload: object): Promise<void>;
|
|
12
|
+
/** Every job written, in order. */
|
|
13
|
+
readonly jobs: ReadonlyArray<{
|
|
14
|
+
readonly queue: string;
|
|
15
|
+
readonly payload: object;
|
|
16
|
+
}>;
|
|
17
|
+
/** Every write with its manager. */
|
|
18
|
+
readonly entries: ReadonlyArray<QueueWrite>;
|
|
19
|
+
jobsOf(queue: string): ReadonlyArray<{
|
|
20
|
+
readonly queue: string;
|
|
21
|
+
readonly payload: object;
|
|
22
|
+
}>;
|
|
23
|
+
/** True when something was written and every write went through a `fakeTransaction` manager. */
|
|
24
|
+
readonly allInTransaction: boolean;
|
|
25
|
+
/** Makes the next write reject with `error`, once. */
|
|
26
|
+
failNext(operation: "write", error: Error): void;
|
|
27
|
+
clear(): void;
|
|
28
|
+
}
|
|
29
|
+
/** The queue outbox double. */
|
|
30
|
+
export declare function recordingQueueOutbox(): RecordingQueueOutbox;
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.recordingQueueOutbox = recordingQueueOutbox;
|
|
4
|
+
/**
|
|
5
|
+
* `recordingQueueOutbox()` -- a `QueueOutbox` double that records what a typed queue producer writes, so a spec asserts on `outbox.jobs`.
|
|
6
|
+
*
|
|
7
|
+
* `write(tx, queue, payload)` records the job with the manager it was written through. `jobs` is every job in order, `jobsOf(queue)`
|
|
8
|
+
* the jobs of one queue, and `entries` adds `inTransaction`: whether `tx` was the view a `fakeTransaction` body received, so a spec proves
|
|
9
|
+
* a job is enqueued in the transaction of the change that needs it. `failNext('write', error)` makes the next write reject once.
|
|
10
|
+
*/
|
|
11
|
+
const entity_manager_ts_1 = require("./entity-manager.js");
|
|
12
|
+
/** The queue outbox double. */
|
|
13
|
+
function recordingQueueOutbox() {
|
|
14
|
+
const written = [];
|
|
15
|
+
const failures = new Map();
|
|
16
|
+
return {
|
|
17
|
+
async write(tx, queue, payload) {
|
|
18
|
+
const error = failures.get("write");
|
|
19
|
+
if (error !== undefined) {
|
|
20
|
+
failures.delete("write");
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
written.push({ queue, payload, tx, inTransaction: (0, entity_manager_ts_1.isTransactionManager)(tx) });
|
|
24
|
+
},
|
|
25
|
+
/** Every job written, in order: `{ queue, payload }`. */
|
|
26
|
+
get jobs() {
|
|
27
|
+
return written.map(({ queue, payload }) => ({ queue, payload }));
|
|
28
|
+
},
|
|
29
|
+
/** Every write with its manager and `inTransaction`. */
|
|
30
|
+
get entries() {
|
|
31
|
+
return [...written];
|
|
32
|
+
},
|
|
33
|
+
/** The jobs of one queue. */
|
|
34
|
+
jobsOf(queue) {
|
|
35
|
+
return this.jobs.filter((job) => job.queue === queue);
|
|
36
|
+
},
|
|
37
|
+
/** True when something was written and every write went through a transaction manager. */
|
|
38
|
+
get allInTransaction() {
|
|
39
|
+
return written.length > 0 && written.every((entry) => entry.inTransaction);
|
|
40
|
+
},
|
|
41
|
+
/** Makes the next write reject with `error`, once. */
|
|
42
|
+
failNext(operation, error) {
|
|
43
|
+
failures.set(operation, error);
|
|
44
|
+
},
|
|
45
|
+
clear() {
|
|
46
|
+
written.length = 0;
|
|
47
|
+
failures.clear();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. A plain list with no require, so
|
|
3
|
+
* @starci/hfs ships the built file (packages/hfs/runtime, scripts/hfs/sync-runtime.mjs) and `starci app scaffold` renders
|
|
4
|
+
* the same exclusions before any app dependency is installed.
|
|
5
|
+
*/
|
|
6
|
+
declare const SONAR_EXCLUSIONS: readonly string[];
|
|
7
|
+
export = SONAR_EXCLUSIONS;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. A plain list with no require, so
|
|
4
|
+
* @starci/hfs ships the built file (packages/hfs/runtime, scripts/hfs/sync-runtime.mjs) and `starci app scaffold` renders
|
|
5
|
+
* the same exclusions before any app dependency is installed.
|
|
6
|
+
*/
|
|
7
|
+
const SONAR_EXCLUSIONS = Object.freeze(["**/*.spec.ts", "**/*.e2e-spec.ts", "**/dist/**", "**/coverage/**"]);
|
|
8
|
+
module.exports = SONAR_EXCLUSIONS;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type TS from "typescript";
|
|
2
|
+
type TypeScript = typeof TS;
|
|
3
|
+
/** What the transformer reads of the ts-jest compiler: the TypeScript module the project compiles with. */
|
|
4
|
+
interface MetadataCompiler {
|
|
5
|
+
readonly configSet: {
|
|
6
|
+
readonly compilerModule: TypeScript;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Only synthetic conditional guards inside compiler-emitted parameter metadata map to the generated-metadata anchor: a zero-width position at its end, which no line covers, so they are measured nowhere and source metadata calls stay measured. */
|
|
10
|
+
declare function factory(compiler: MetadataCompiler): TS.TransformerFactory<TS.SourceFile>;
|
|
11
|
+
/** Unit transforms delegate compilation and caching to ts-jest; the after-transformer changes metadata mappings only. */
|
|
12
|
+
declare function createTransformer(options?: Parameters<typeof import("ts-jest").default.createTransformer>[0]): {
|
|
13
|
+
getCacheKey: (fileContent: string, filePath: string, transformOptions: import("ts-jest").TsJestTransformOptions) => string;
|
|
14
|
+
process: (sourceText: string, sourcePath: string, transformOptions: import("ts-jest").TsJestTransformOptions) => import("@jest/transform").TransformedSource;
|
|
15
|
+
processAsync: (sourceText: string, sourcePath: string, transformOptions: import("ts-jest").TsJestTransformOptions) => Promise<import("@jest/transform").TransformedSource>;
|
|
16
|
+
};
|
|
17
|
+
declare const unitMetadata: {
|
|
18
|
+
name: string;
|
|
19
|
+
version: number;
|
|
20
|
+
factory: typeof factory;
|
|
21
|
+
createTransformer: typeof createTransformer;
|
|
22
|
+
};
|
|
23
|
+
export = unitMetadata;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const node_fs_1 = require("node:fs");
|
|
3
|
+
const METADATA_SOURCE = require.resolve("./unit-metadata.generated.js");
|
|
4
|
+
const METADATA_TEXT = (0, node_fs_1.readFileSync)(METADATA_SOURCE, "utf8");
|
|
5
|
+
const hasEmitFlags = (ts) => "getEmitFlags" in ts && typeof ts.getEmitFlags === "function";
|
|
6
|
+
function emitFlagsOf(ts, node) {
|
|
7
|
+
if (!hasEmitFlags(ts))
|
|
8
|
+
throw new TypeError("ts.getEmitFlags is not a function");
|
|
9
|
+
return ts.getEmitFlags(node);
|
|
10
|
+
}
|
|
11
|
+
/** Only synthetic conditional guards inside compiler-emitted parameter metadata map to the generated-metadata anchor: a zero-width position at its end, which no line covers, so they are measured nowhere and source metadata calls stay measured. */
|
|
12
|
+
function factory(compiler) {
|
|
13
|
+
const ts = compiler.configSet.compilerModule;
|
|
14
|
+
const source = ts.createSourceMapSource(METADATA_SOURCE, METADATA_TEXT);
|
|
15
|
+
return (context) => {
|
|
16
|
+
const visit = (node) => {
|
|
17
|
+
if (isGeneratedMetadata(ts, node)) {
|
|
18
|
+
const guard = (child) => {
|
|
19
|
+
if (isGeneratedGuard(ts, child)) {
|
|
20
|
+
ts.setSourceMapRange(child, { pos: METADATA_TEXT.length, end: METADATA_TEXT.length, source });
|
|
21
|
+
ts.setEmitFlags(child, emitFlagsOf(ts, child) | ts.EmitFlags.NoNestedSourceMaps);
|
|
22
|
+
return child;
|
|
23
|
+
}
|
|
24
|
+
return ts.visitEachChild(child, guard, context);
|
|
25
|
+
};
|
|
26
|
+
return ts.visitEachChild(node, guard, context);
|
|
27
|
+
}
|
|
28
|
+
return ts.visitEachChild(node, visit, context);
|
|
29
|
+
};
|
|
30
|
+
return (file) => ts.visitNode(file, visit, ts.isSourceFile);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Only TypeScript's synthetic __metadata("design:paramtypes", ...) call is eligible; source calls remain measured. */
|
|
34
|
+
function isGeneratedMetadata(ts, node) {
|
|
35
|
+
if (node.pos >= 0 || !ts.isCallExpression(node))
|
|
36
|
+
return false;
|
|
37
|
+
const key = node.arguments[0];
|
|
38
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "__metadata" && node.arguments.length === 2 && key !== undefined && ts.isStringLiteral(key) && key.text === "design:paramtypes";
|
|
39
|
+
}
|
|
40
|
+
/** A metadata exclusion requires TypeScript's synthetic `typeof value === "function" ? value : Object` guard shape. */
|
|
41
|
+
function isGeneratedGuard(ts, node) {
|
|
42
|
+
if (node.pos >= 0 || !ts.isConditionalExpression(node))
|
|
43
|
+
return false;
|
|
44
|
+
const condition = node.condition;
|
|
45
|
+
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";
|
|
46
|
+
}
|
|
47
|
+
/** ts-jest normalizes map sources to the input file; restore the generated metadata anchor before Jest reads the map. */
|
|
48
|
+
function restoreMetadataSource(result) {
|
|
49
|
+
const prefix = "sourceMappingURL=data:application/json;charset=utf-8;base64,";
|
|
50
|
+
const offset = result.code.lastIndexOf(prefix);
|
|
51
|
+
if (offset < 0)
|
|
52
|
+
return result;
|
|
53
|
+
const start = offset + prefix.length;
|
|
54
|
+
const map = JSON.parse(Buffer.from(result.code.slice(start), "base64").toString("utf8"));
|
|
55
|
+
if (map.sourcesContent?.length !== 2 || map.sourcesContent[1] !== METADATA_TEXT)
|
|
56
|
+
return result;
|
|
57
|
+
map.sources = [map.sources[0] ?? "", METADATA_SOURCE];
|
|
58
|
+
return { ...result, code: result.code.slice(0, start) + Buffer.from(JSON.stringify(map)).toString("base64") };
|
|
59
|
+
}
|
|
60
|
+
/** Unit transforms delegate compilation and caching to ts-jest; the after-transformer changes metadata mappings only. */
|
|
61
|
+
function createTransformer(options) {
|
|
62
|
+
const tsJest = require("ts-jest");
|
|
63
|
+
const delegate = tsJest.default.createTransformer(options);
|
|
64
|
+
return {
|
|
65
|
+
getCacheKey: (...args) => delegate.getCacheKey(...args) + ":starci-unit-metadata-6",
|
|
66
|
+
process: (...args) => restoreMetadataSource(delegate.process(...args)),
|
|
67
|
+
processAsync: async (...args) => restoreMetadataSource(await delegate.processAsync(...args)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const unitMetadata = { name: "starci-unit-metadata", version: 6, factory, createTransformer };
|
|
71
|
+
module.exports = unitMetadata;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner of the integration, e2e and contract projects: every spec file runs in a process of its own, bound to one data
|
|
3
|
+
* slot of the test world.
|
|
4
|
+
*
|
|
5
|
+
* Why a process per file: jest runs a file in a fresh vm context, but a context still reaches the globals of the process that
|
|
6
|
+
* hosts it, and jest-environment-node exposes every global the host process had when the environment loaded. The test world's
|
|
7
|
+
* globalSetup runs in jest's main process and loads application code there (the migrate step and its modules), so a framework
|
|
8
|
+
* that keeps its registry on a process global (`@nestjs/graphql` keeps `GqlTypeMetadataStorage` on `global`) leaks it into
|
|
9
|
+
* every file jest runs in that process; and a worker that runs several files carries whatever the first one left on its own
|
|
10
|
+
* global. A process per file isolates every framework at once.
|
|
11
|
+
*
|
|
12
|
+
* Why slots: the test world resets a slot's data (its databases, realm users, Redis DB, buckets and fakes) when a file boots,
|
|
13
|
+
* so two files must never share a slot at the same time. The globalSetup of `@starci/test-world` provisions N slots, each a
|
|
14
|
+
* complete, independent set of data namespaces, and publishes them in its state file (`STARCI_TEST_WORLD_STATE`, protocol
|
|
15
|
+
* {@link WORLD_STATE_VERSION}). The runner runs up to `min(--maxWorkers, N)` files at once and gives each a free slot: the
|
|
16
|
+
* file's process is forked with `STARCI_TEST_WORLD_SLOT=<k>`; the slot goes back to the pool when the file ends.
|
|
17
|
+
*
|
|
18
|
+
* How: the stock jest-runner, driven one file at a time per slot with a single-worker farm (`maxWorkers: 1` per file), which
|
|
19
|
+
* forks a fresh child, runs the file there and ends the child. jest-worker forks in the farm's constructor, which jest-runner
|
|
20
|
+
* calls before its first `await`, so the slot variable is set on `process.env` only for that synchronous span. Never in band,
|
|
21
|
+
* even when jest would choose it (`--runInBand`): the main process holds the globalSetup's state.
|
|
22
|
+
*
|
|
23
|
+
* The pair is exact: this runner reads protocol {@link WORLD_STATE_VERSION} only. Any other state file is the wrong
|
|
24
|
+
* `@starci/test-world` beside this preset, a typed failure naming both versions (re-pin both per canon-pins), never a fallback.
|
|
25
|
+
*/
|
|
26
|
+
import type TestRunner from "jest-runner";
|
|
27
|
+
import type { Test, TestEvents, TestWatcher, UnsubscribeFn } from "jest-runner";
|
|
28
|
+
/** The slot count the world published; a missing or foreign state file is a pair mismatch. */
|
|
29
|
+
declare function worldSlots(env?: NodeJS.ProcessEnv): number;
|
|
30
|
+
declare const StockRunner: typeof TestRunner;
|
|
31
|
+
declare class WorldRunner extends StockRunner {
|
|
32
|
+
#private;
|
|
33
|
+
static readonly WORLD_STATE_VERSION = 2;
|
|
34
|
+
static readonly SLOT_ENV = "STARCI_TEST_WORLD_SLOT";
|
|
35
|
+
static readonly WORLD_PAIR_MISMATCH = "JEST_PRESET_WORLD_PAIR_MISMATCH";
|
|
36
|
+
static readonly worldSlots: typeof worldSlots;
|
|
37
|
+
/** Jest subscribes before the run; the subscriptions are handed to the per-file runner of each file. */
|
|
38
|
+
on<Name extends keyof TestEvents>(eventName: Name, listener: (eventData: TestEvents[Name]) => void | Promise<void>): UnsubscribeFn;
|
|
39
|
+
/** Runs the files up to `min(--maxWorkers, slots)` at once, each in a fresh process bound to a free slot; never in band. */
|
|
40
|
+
runTests(tests: Test[], watcher: TestWatcher): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export = WorldRunner;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The runner of the integration, e2e and contract projects: every spec file runs in a process of its own, bound to one data
|
|
4
|
+
* slot of the test world.
|
|
5
|
+
*
|
|
6
|
+
* Why a process per file: jest runs a file in a fresh vm context, but a context still reaches the globals of the process that
|
|
7
|
+
* hosts it, and jest-environment-node exposes every global the host process had when the environment loaded. The test world's
|
|
8
|
+
* globalSetup runs in jest's main process and loads application code there (the migrate step and its modules), so a framework
|
|
9
|
+
* that keeps its registry on a process global (`@nestjs/graphql` keeps `GqlTypeMetadataStorage` on `global`) leaks it into
|
|
10
|
+
* every file jest runs in that process; and a worker that runs several files carries whatever the first one left on its own
|
|
11
|
+
* global. A process per file isolates every framework at once.
|
|
12
|
+
*
|
|
13
|
+
* Why slots: the test world resets a slot's data (its databases, realm users, Redis DB, buckets and fakes) when a file boots,
|
|
14
|
+
* so two files must never share a slot at the same time. The globalSetup of `@starci/test-world` provisions N slots, each a
|
|
15
|
+
* complete, independent set of data namespaces, and publishes them in its state file (`STARCI_TEST_WORLD_STATE`, protocol
|
|
16
|
+
* {@link WORLD_STATE_VERSION}). The runner runs up to `min(--maxWorkers, N)` files at once and gives each a free slot: the
|
|
17
|
+
* file's process is forked with `STARCI_TEST_WORLD_SLOT=<k>`; the slot goes back to the pool when the file ends.
|
|
18
|
+
*
|
|
19
|
+
* How: the stock jest-runner, driven one file at a time per slot with a single-worker farm (`maxWorkers: 1` per file), which
|
|
20
|
+
* forks a fresh child, runs the file there and ends the child. jest-worker forks in the farm's constructor, which jest-runner
|
|
21
|
+
* calls before its first `await`, so the slot variable is set on `process.env` only for that synchronous span. Never in band,
|
|
22
|
+
* even when jest would choose it (`--runInBand`): the main process holds the globalSetup's state.
|
|
23
|
+
*
|
|
24
|
+
* The pair is exact: this runner reads protocol {@link WORLD_STATE_VERSION} only. Any other state file is the wrong
|
|
25
|
+
* `@starci/test-world` beside this preset, a typed failure naming both versions (re-pin both per canon-pins), never a fallback.
|
|
26
|
+
*/
|
|
27
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
28
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
29
|
+
};
|
|
30
|
+
const node_module_1 = require("node:module");
|
|
31
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
32
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
33
|
+
/** The state-file protocol of `@starci/test-world` this runner speaks (test-world 1.1.x). */
|
|
34
|
+
const WORLD_STATE_VERSION = 2;
|
|
35
|
+
/** The variable that carries the path of the world's state file (set by its globalSetup in this process). */
|
|
36
|
+
const STATE_FILE_ENV = "STARCI_TEST_WORLD_STATE";
|
|
37
|
+
/** The variable that tells a file's process which slot it owns (1-based). */
|
|
38
|
+
const SLOT_ENV = "STARCI_TEST_WORLD_SLOT";
|
|
39
|
+
/** The failure code of a state file this runner cannot drive. */
|
|
40
|
+
const WORLD_PAIR_MISMATCH = "JEST_PRESET_WORLD_PAIR_MISMATCH";
|
|
41
|
+
const PRESET = (() => {
|
|
42
|
+
// dist/ sits one level below the package root that holds package.json
|
|
43
|
+
const own = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, "..", "package.json"), "utf8"));
|
|
44
|
+
return `${own.name}@${own.version}`;
|
|
45
|
+
})();
|
|
46
|
+
/** A typed failure of the runner: `code` names it, the message says what to do. */
|
|
47
|
+
function runnerError(code, detail) {
|
|
48
|
+
const error = new Error(`${code}: ${detail}`);
|
|
49
|
+
return Object.assign(error, { code });
|
|
50
|
+
}
|
|
51
|
+
/** A property of an unknown value, `undefined` when the value is not an object that has it. */
|
|
52
|
+
const fieldOf = (value, name) => (typeof value === "object" && value !== null && name in value ? Reflect.get(value, name) : undefined);
|
|
53
|
+
/** The slot count the world published; a missing or foreign state file is a pair mismatch. */
|
|
54
|
+
function worldSlots(env = process.env) {
|
|
55
|
+
const file = env[STATE_FILE_ENV];
|
|
56
|
+
const fix = `pin @starci/test-world and @starci/jest-preset together as knowledge/hfs/canon-pins.yaml pairs them (${PRESET} speaks state protocol ${WORLD_STATE_VERSION}, @starci/test-world 1.1.x)`;
|
|
57
|
+
if (file === undefined || file === "") {
|
|
58
|
+
throw runnerError(WORLD_PAIR_MISMATCH, `${STATE_FILE_ENV} is not set: the world's globalSetup published no state file for ${PRESET}; ${fix}`);
|
|
59
|
+
}
|
|
60
|
+
let state;
|
|
61
|
+
try {
|
|
62
|
+
state = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
throw runnerError(WORLD_PAIR_MISMATCH, `the world state file ${file} cannot be read (${cause instanceof Error ? cause.message : String(cause)}); ${fix}`);
|
|
66
|
+
}
|
|
67
|
+
const published = fieldOf(state, "library");
|
|
68
|
+
const library = typeof published === "string" ? published : "@starci/test-world before 1.1.0";
|
|
69
|
+
const slots = fieldOf(state, "slots");
|
|
70
|
+
if (fieldOf(state, "version") !== WORLD_STATE_VERSION || !Array.isArray(slots) || slots.length < 1) {
|
|
71
|
+
throw runnerError(WORLD_PAIR_MISMATCH, `${library} wrote state protocol ${String(fieldOf(state, "version"))}, ${PRESET} needs ${WORLD_STATE_VERSION}; ${fix}`);
|
|
72
|
+
}
|
|
73
|
+
return slots.length;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The stock jest-runner of the jest that loads this preset: resolved through jest's own dependency chain
|
|
77
|
+
* (jest -> @jest/core -> jest-runner) from the repository root, so the runner always matches the running jest.
|
|
78
|
+
*/
|
|
79
|
+
function stockRunner(root = process.cwd()) {
|
|
80
|
+
const fromRoot = (0, node_module_1.createRequire)(node_path_1.default.join(root, "package.json"));
|
|
81
|
+
const fromJest = (0, node_module_1.createRequire)(fromRoot.resolve("jest/package.json"));
|
|
82
|
+
const fromCore = (0, node_module_1.createRequire)(fromJest.resolve("@jest/core"));
|
|
83
|
+
const loaded = fromCore("jest-runner");
|
|
84
|
+
return loaded.default ?? loaded;
|
|
85
|
+
}
|
|
86
|
+
const StockRunner = stockRunner();
|
|
87
|
+
class WorldRunner extends StockRunner {
|
|
88
|
+
static WORLD_STATE_VERSION = WORLD_STATE_VERSION;
|
|
89
|
+
static SLOT_ENV = SLOT_ENV;
|
|
90
|
+
static WORLD_PAIR_MISMATCH = WORLD_PAIR_MISMATCH;
|
|
91
|
+
static worldSlots = worldSlots;
|
|
92
|
+
#listeners = [];
|
|
93
|
+
/** Jest subscribes before the run; the subscriptions are handed to the per-file runner of each file. */
|
|
94
|
+
on(eventName, listener) {
|
|
95
|
+
const entry = { attach: (runner) => runner.on(eventName, listener) };
|
|
96
|
+
this.#listeners.push(entry);
|
|
97
|
+
return () => {
|
|
98
|
+
this.#listeners = this.#listeners.filter((candidate) => candidate !== entry);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Runs the files up to `min(--maxWorkers, slots)` at once, each in a fresh process bound to a free slot; never in band. */
|
|
102
|
+
async runTests(tests, watcher) {
|
|
103
|
+
const slots = worldSlots();
|
|
104
|
+
const width = Math.max(1, Math.min(this._globalConfig.maxWorkers ?? 1, slots));
|
|
105
|
+
const queue = [...tests];
|
|
106
|
+
// one lane per slot: a lane owns its slot for the whole run and takes the next file when its current one ends
|
|
107
|
+
const lane = async (slot) => {
|
|
108
|
+
for (let test = queue.shift(); test !== undefined; test = queue.shift()) {
|
|
109
|
+
if (watcher.isInterrupted())
|
|
110
|
+
return;
|
|
111
|
+
await this.#runOne(test, watcher, slot);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
await Promise.all(Array.from({ length: Math.min(width, tests.length) }, (_, index) => lane(index + 1)));
|
|
115
|
+
}
|
|
116
|
+
/** One file in a fresh single-worker farm whose child is forked with the slot in its environment. */
|
|
117
|
+
async #runOne(test, watcher, slot) {
|
|
118
|
+
const runner = new StockRunner({ ...this._globalConfig, maxWorkers: 1 }, this._context);
|
|
119
|
+
const unsubscribe = this.#listeners.map(({ attach }) => attach(runner));
|
|
120
|
+
const before = process.env[SLOT_ENV];
|
|
121
|
+
let running;
|
|
122
|
+
process.env[SLOT_ENV] = String(slot);
|
|
123
|
+
try {
|
|
124
|
+
// the farm (and its child) is created synchronously inside this call, before jest-runner's first await
|
|
125
|
+
running = runner.runTests([test], watcher, { serial: false });
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (before === undefined)
|
|
129
|
+
delete process.env[SLOT_ENV];
|
|
130
|
+
else
|
|
131
|
+
process.env[SLOT_ENV] = before;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await running;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
for (const stop of unsubscribe)
|
|
138
|
+
stop();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
module.exports = WorldRunner;
|
package/package.json
CHANGED
|
@@ -1,69 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/jest-preset",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
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
|
+
"author": "starci183",
|
|
7
|
+
"homepage": "https://github.com/starci183/starci-harness/tree/main/packages/jest-preset#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/starci183/starci-harness.git",
|
|
11
|
+
"directory": "packages/jest-preset"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/starci183/starci-harness/issues"
|
|
15
|
+
},
|
|
6
16
|
"private": false,
|
|
7
|
-
"
|
|
8
|
-
"
|
|
17
|
+
"type": "commonjs",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
9
20
|
"exports": {
|
|
10
21
|
".": {
|
|
11
|
-
"types": "./index.d.ts",
|
|
12
|
-
"default": "./index.
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
13
24
|
},
|
|
14
25
|
"./mock": {
|
|
15
|
-
"types": "./mock.d.ts",
|
|
16
|
-
"default": "./mock.
|
|
26
|
+
"types": "./dist/mock.d.ts",
|
|
27
|
+
"default": "./dist/mock.js"
|
|
17
28
|
},
|
|
18
29
|
"./clock": {
|
|
19
|
-
"types": "./clock.d.ts",
|
|
20
|
-
"default": "./clock.
|
|
30
|
+
"types": "./dist/clock.d.ts",
|
|
31
|
+
"default": "./dist/clock.js"
|
|
21
32
|
},
|
|
22
33
|
"./entity-manager": {
|
|
23
|
-
"types": "./entity-manager.d.ts",
|
|
24
|
-
"default": "./entity-manager.
|
|
34
|
+
"types": "./dist/entity-manager.d.ts",
|
|
35
|
+
"default": "./dist/entity-manager.js"
|
|
25
36
|
},
|
|
26
37
|
"./ids": {
|
|
27
|
-
"types": "./ids.d.ts",
|
|
28
|
-
"default": "./ids.
|
|
38
|
+
"types": "./dist/ids.d.ts",
|
|
39
|
+
"default": "./dist/ids.js"
|
|
29
40
|
},
|
|
30
41
|
"./matchers": {
|
|
31
|
-
"types": "./matchers.d.ts",
|
|
32
|
-
"default": "./matchers.
|
|
42
|
+
"types": "./dist/matchers.d.ts",
|
|
43
|
+
"default": "./dist/matchers.js"
|
|
33
44
|
},
|
|
34
45
|
"./cache": {
|
|
35
|
-
"types": "./cache.d.ts",
|
|
36
|
-
"default": "./cache.
|
|
46
|
+
"types": "./dist/cache.d.ts",
|
|
47
|
+
"default": "./dist/cache.js"
|
|
37
48
|
},
|
|
38
49
|
"./lock": {
|
|
39
|
-
"types": "./lock.d.ts",
|
|
40
|
-
"default": "./lock.
|
|
50
|
+
"types": "./dist/lock.d.ts",
|
|
51
|
+
"default": "./dist/lock.js"
|
|
41
52
|
},
|
|
42
53
|
"./event-bus": {
|
|
43
|
-
"types": "./event-bus.d.ts",
|
|
44
|
-
"default": "./event-bus.
|
|
54
|
+
"types": "./dist/event-bus.d.ts",
|
|
55
|
+
"default": "./dist/event-bus.js"
|
|
45
56
|
},
|
|
46
57
|
"./queue": {
|
|
47
|
-
"types": "./queue.d.ts",
|
|
48
|
-
"default": "./queue.
|
|
58
|
+
"types": "./dist/queue.d.ts",
|
|
59
|
+
"default": "./dist/queue.js"
|
|
49
60
|
},
|
|
50
61
|
"./inbox": {
|
|
51
|
-
"types": "./inbox.d.ts",
|
|
52
|
-
"default": "./inbox.
|
|
62
|
+
"types": "./dist/inbox.d.ts",
|
|
63
|
+
"default": "./dist/inbox.js"
|
|
53
64
|
},
|
|
54
65
|
"./builders": {
|
|
55
|
-
"types": "./builders.d.ts",
|
|
56
|
-
"default": "./builders.
|
|
66
|
+
"types": "./dist/builders.d.ts",
|
|
67
|
+
"default": "./dist/builders.js"
|
|
57
68
|
},
|
|
58
69
|
"./merge": {
|
|
59
|
-
"
|
|
70
|
+
"types": "./dist/merge.d.ts",
|
|
71
|
+
"default": "./dist/merge.js"
|
|
60
72
|
}
|
|
61
73
|
},
|
|
62
74
|
"files": [
|
|
75
|
+
"dist",
|
|
63
76
|
"LICENSE",
|
|
64
|
-
"*.cjs",
|
|
65
|
-
"*.d.ts",
|
|
66
|
-
"!*.spec.*",
|
|
67
77
|
"README.md",
|
|
68
78
|
"CHANGELOG.md"
|
|
69
79
|
],
|
|
@@ -78,7 +88,10 @@
|
|
|
78
88
|
"typeorm": ">=0.3"
|
|
79
89
|
},
|
|
80
90
|
"scripts": {
|
|
81
|
-
"
|
|
91
|
+
"build": "node -e \"require('node:fs').rmSync('dist', {recursive: true, force: true})\" && tsc -p tsconfig.build.json && node -e \"require('node:fs').copyFileSync('assets/unit-metadata.generated.js', 'dist/unit-metadata.generated.js')\"",
|
|
92
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
93
|
+
"prepack": "npm run build",
|
|
94
|
+
"test": "npm run build && npm run typecheck && node --test \"test/**/*.spec.ts\""
|
|
82
95
|
},
|
|
83
96
|
"peerDependenciesMeta": {
|
|
84
97
|
"typeorm": {
|
|
@@ -89,11 +102,18 @@
|
|
|
89
102
|
}
|
|
90
103
|
},
|
|
91
104
|
"devDependencies": {
|
|
105
|
+
"@starci/tsconfig": "file:../tsconfig",
|
|
106
|
+
"@types/istanbul-lib-coverage": "2.0.6",
|
|
107
|
+
"@types/istanbul-lib-report": "3.0.3",
|
|
108
|
+
"@types/istanbul-reports": "3.0.4",
|
|
92
109
|
"@types/jest": "29.5.14",
|
|
110
|
+
"@types/node": "24.19.2",
|
|
93
111
|
"jest": "29.7.0",
|
|
112
|
+
"jest-runner": "29.7.0",
|
|
94
113
|
"jest-util": "29.7.0",
|
|
95
114
|
"ts-jest": "29.4.12",
|
|
96
115
|
"tslib": "2.8.1",
|
|
116
|
+
"typeorm": "0.3.27",
|
|
97
117
|
"typescript": "5.9.3"
|
|
98
118
|
}
|
|
99
119
|
}
|
package/builders.d.ts
DELETED
package/cache.cjs
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"use strict"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `fakeCache(clock)` -- a behavioural in-memory double of a cache port.
|
|
5
|
-
*
|
|
6
|
-
* It keeps entries as JSON text (like a Redis-backed cache, so a Date comes back as a string and a `key.parse` narrows
|
|
7
|
-
* what was stored), expires them from the `FakeClock` (`clock.advance(ms)` is a TTL expiry, no sleep), and offers the
|
|
8
|
-
* inspection a spec needs: `has(key)`, `ttlOf(key)`, `keys()`.
|
|
9
|
-
*
|
|
10
|
-
* Two call shapes are served by the same object, so it stands in for either kind of cache dependency:
|
|
11
|
-
* - the typed-key port: `get({ key, args })`, `set({ key, args, value })`, `del({ key, args })`, where `key` is
|
|
12
|
-
* `{ name, ttl: { seconds }, parse? }` and the entry text is `<name>:<arg>:...`;
|
|
13
|
-
* - the cache-manager shape: `get(text)`, `set(text, value, ttlMs?)`, `del(text)`.
|
|
14
|
-
* `has`, `ttlOf` and `del` accept a request or the entry text.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const textOf = (request) => (typeof request === "string" ? request : [request.key.name, ...(request.args ?? [])].join(":"))
|
|
18
|
-
|
|
19
|
-
/** @param {{ now(): Date }} clock */
|
|
20
|
-
function fakeCache(clock) {
|
|
21
|
-
if (!clock || typeof clock.now !== "function") throw new Error("fakeCache: pass the FakeClock the spec drives, fakeCache(clock)")
|
|
22
|
-
const entries = new Map()
|
|
23
|
-
|
|
24
|
-
const live = (text) => {
|
|
25
|
-
const entry = entries.get(text)
|
|
26
|
-
if (entry === undefined) return undefined
|
|
27
|
-
if (entry.expiresAt !== null && clock.now().getTime() >= entry.expiresAt) {
|
|
28
|
-
entries.delete(text)
|
|
29
|
-
return undefined
|
|
30
|
-
}
|
|
31
|
-
return entry
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
async get(request) {
|
|
36
|
-
const entry = live(textOf(request))
|
|
37
|
-
if (entry === undefined) return null
|
|
38
|
-
const stored = JSON.parse(entry.json)
|
|
39
|
-
return typeof request === "string" || typeof request.key.parse !== "function" ? stored : request.key.parse(stored)
|
|
40
|
-
},
|
|
41
|
-
async set(request, value, ttlMs) {
|
|
42
|
-
const text = textOf(request)
|
|
43
|
-
const typed = typeof request !== "string"
|
|
44
|
-
const stored = typed ? request.value : value
|
|
45
|
-
const lifetimeMs = typed ? request.key.ttl.seconds * 1000 : ttlMs
|
|
46
|
-
if (lifetimeMs !== undefined && !(lifetimeMs > 0)) throw new Error(`fakeCache: the ttl of ${text} must be positive`)
|
|
47
|
-
entries.set(text, { json: JSON.stringify(stored), expiresAt: lifetimeMs === undefined ? null : clock.now().getTime() + lifetimeMs })
|
|
48
|
-
},
|
|
49
|
-
async del(request) {
|
|
50
|
-
entries.delete(textOf(request))
|
|
51
|
-
},
|
|
52
|
-
/** Whether a live (not expired) entry exists. */
|
|
53
|
-
has: (request) => live(textOf(request)) !== undefined,
|
|
54
|
-
/** Seconds left before the entry expires (rounded up), null when there is no live entry or it never expires. */
|
|
55
|
-
ttlOf(request) {
|
|
56
|
-
const entry = live(textOf(request))
|
|
57
|
-
if (entry === undefined || entry.expiresAt === null) return null
|
|
58
|
-
return Math.ceil((entry.expiresAt - clock.now().getTime()) / 1000)
|
|
59
|
-
},
|
|
60
|
-
/** The texts of the live entries. */
|
|
61
|
-
keys: () => [...entries.keys()].filter((text) => live(text) !== undefined),
|
|
62
|
-
/** Drops every entry. */
|
|
63
|
-
clear: () => entries.clear(),
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
module.exports = { fakeCache }
|
package/cache.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/** The declared key of a typed cache entry: `{ name, ttl, parse? }`. */
|
|
2
|
-
export interface FakeCacheKey<TValue = unknown> {
|
|
3
|
-
readonly name: string
|
|
4
|
-
readonly ttl: { readonly seconds: number }
|
|
5
|
-
readonly parse?: (stored: unknown) => TValue | null
|
|
6
|
-
}
|
|
7
|
-
/** The typed-key request: the key plus the arguments that make the entry unique. */
|
|
8
|
-
export interface FakeCacheRequest<TValue = unknown> {
|
|
9
|
-
readonly key: FakeCacheKey<TValue>
|
|
10
|
-
readonly args?: ReadonlyArray<string>
|
|
11
|
-
}
|
|
12
|
-
export type FakeCacheEntry<TValue = unknown> = FakeCacheRequest<TValue> & { readonly value: TValue }
|
|
13
|
-
|
|
14
|
-
/** A behavioural in-memory cache: JSON round trip, expiry driven by the clock, inspection helpers. */
|
|
15
|
-
export interface FakeCache {
|
|
16
|
-
get<TValue>(request: FakeCacheRequest<TValue>): Promise<TValue | null>
|
|
17
|
-
get(text: string): Promise<unknown>
|
|
18
|
-
set<TValue>(entry: FakeCacheEntry<TValue>): Promise<void>
|
|
19
|
-
set(text: string, value: unknown, ttlMs?: number): Promise<void>
|
|
20
|
-
del(request: FakeCacheRequest | string): Promise<void>
|
|
21
|
-
/** Whether a live (not expired) entry exists. */
|
|
22
|
-
has(request: FakeCacheRequest | string): boolean
|
|
23
|
-
/** Seconds left before the entry expires (rounded up); null when absent, expired or without a ttl. */
|
|
24
|
-
ttlOf(request: FakeCacheRequest | string): number | null
|
|
25
|
-
/** The texts of the live entries. */
|
|
26
|
-
keys(): string[]
|
|
27
|
-
clear(): void
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** The cache double; `clock` is the `FakeClock` of the spec, so `clock.advance(ms)` expires entries. */
|
|
31
|
-
export declare function fakeCache(clock: { now(): Date }): FakeCache
|