@starci/jest-preset 1.1.0 → 2.1.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 +17 -0
- package/README.md +133 -20
- package/builders.cjs +15 -0
- package/builders.d.ts +2 -0
- package/cache.cjs +67 -0
- package/cache.d.ts +31 -0
- package/entity-manager.cjs +145 -0
- package/entity-manager.d.ts +103 -0
- package/ids.cjs +41 -0
- package/ids.d.ts +13 -0
- package/index.cjs +100 -43
- package/index.d.ts +14 -11
- package/lock.cjs +56 -0
- package/lock.d.ts +26 -0
- package/matchers.cjs +45 -0
- package/matchers.d.ts +17 -0
- package/mock.cjs +7 -7
- package/outbox.cjs +125 -0
- package/outbox.d.ts +79 -0
- package/package.json +39 -4
package/index.cjs
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
"use strict"
|
|
2
2
|
|
|
3
3
|
const { mock, createMock } = require("./mock.cjs")
|
|
4
|
+
const { mockEntityManager, fakeTransaction } = require("./entity-manager.cjs")
|
|
5
|
+
const { FakeClock } = require("./clock.cjs")
|
|
6
|
+
const { fakeCache } = require("./cache.cjs")
|
|
7
|
+
const { fakeLock } = require("./lock.cjs")
|
|
8
|
+
const { recordingOutbox } = require("./outbox.cjs")
|
|
9
|
+
const { builder } = require("./builders.cjs")
|
|
10
|
+
const { fakeIds, FakeIds } = require("./ids.cjs")
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Sonar
|
|
10
|
-
* coverage.exclusions = src/tests/**, e2e specs, entrypoints and declaration files.
|
|
13
|
+
* Coverage is measured on services only: the one place business logic lives (owner-locked unit standard). Handlers,
|
|
14
|
+
* resolvers, controllers and consumers are thin, and helpers called by a service are covered through the service's own
|
|
15
|
+
* spec, so every `*.service.ts` under `src` is the whole denominator and every file in it must reach 100 on every metric.
|
|
16
|
+
* Sonar does not read coverage at all (the gate fails on imported issues only), so there is no second list to keep in step.
|
|
11
17
|
*/
|
|
12
|
-
const COVERAGE_SOURCES = ["src/**/*.ts"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const COVERAGE_ONLY_EXCLUSIONS = ["src/tests/**", "**/*.d.ts", "**/main.ts"]
|
|
17
|
-
const COVERAGE_EXCLUDES = [...SONAR_EXCLUSIONS, ...COVERAGE_ONLY_EXCLUSIONS]
|
|
18
|
+
const COVERAGE_SOURCES = ["src/**/*.service.ts"]
|
|
19
|
+
const COVERAGE_EXCLUDES = ["src/tests/**", "**/dist/**", "**/coverage/**"]
|
|
20
|
+
/** The four metrics, each held at 100 per file. */
|
|
21
|
+
const COVERAGE_THRESHOLD = Object.freeze({ lines: 100, branches: 100, functions: 100, statements: 100 })
|
|
18
22
|
|
|
19
23
|
function collectCoverageFrom() {
|
|
20
24
|
return [...COVERAGE_SOURCES, ...COVERAGE_EXCLUDES.map((glob) => `!${glob}`)]
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
return COVERAGE_ONLY_EXCLUSIONS.join(",")
|
|
26
|
-
}
|
|
27
|
+
/** Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. */
|
|
28
|
+
const SONAR_EXCLUSIONS = ["**/*.spec.ts", "**/*.e2e-spec.ts", "**/dist/**", "**/coverage/**"]
|
|
27
29
|
|
|
28
30
|
function sonarExclusions() {
|
|
29
31
|
return SONAR_EXCLUSIONS.join(",")
|
|
@@ -33,8 +35,8 @@ const TS_TRANSFORM = String.raw`^.+\.ts$`
|
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* ts-jest without a type check (`diagnostics: false`; `isolatedModules: true` comes from @starci/tsconfig, which is where
|
|
36
|
-
* ts-jest 29.4 reads it — the ts-jest option of the same name is deprecated): types are checked once, by `typecheck` / `typecheck:
|
|
37
|
-
* `npm run test:e2e`
|
|
38
|
+
* 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.
|
|
39
|
+
* `npm run test:e2e` (and `test:integration`, `test:contract`) runs `npm run typecheck:tests` first so nobody runs a world spec on code that
|
|
38
40
|
* does not type-check.
|
|
39
41
|
*/
|
|
40
42
|
function transform(tsconfig) {
|
|
@@ -42,59 +44,114 @@ function transform(tsconfig) {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
|
-
* The
|
|
47
|
+
* The compiler options the UNIT project overlays on the repository tsconfig (ts-jest merges an inline `tsconfig` object over the
|
|
48
|
+
* `tsconfig.json` it finds under `rootDir`). They exist so that per-file 100 coverage is reachable for a decorated service:
|
|
49
|
+
* - `isolatedModules: false`: with it on, TypeScript cannot tell whether an imported name is a class, and emits a
|
|
50
|
+
* `typeof (_a = typeof Dep !== "undefined" && Dep) === "function" ? _a : Object` guard per class-typed constructor parameter for
|
|
51
|
+
* `design:paramtypes`; the `Object` arm can never run, so the branch stays uncovered. With the full language service the
|
|
52
|
+
* metadata is emitted as plain `Dep`.
|
|
53
|
+
* - `importHelpers: true`: the decorator helpers (`__decorate`, `__param`, `__metadata`) come from `tslib`, not inlined per file
|
|
54
|
+
* with branches of their own. The repository lists `tslib` in its devDependencies.
|
|
55
|
+
* Integration, e2e and contract keep the repository setting: they measure nothing.
|
|
56
|
+
*/
|
|
57
|
+
const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: false, importHelpers: true })
|
|
58
|
+
|
|
59
|
+
function unitTransform() {
|
|
60
|
+
return transform({ ...UNIT_COMPILER_OPTIONS })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The path aliases of a StarCi back end: the same three `paths` the managed `tsconfig.json` declares, so jest resolves what
|
|
65
|
+
* `tsc` resolves. They are part of the preset, not an option: a repository has no other alias.
|
|
66
|
+
*/
|
|
67
|
+
const MODULE_NAME_MAPPER = Object.freeze({
|
|
68
|
+
"^@features/(.*)$": "<rootDir>/src/features/$1",
|
|
69
|
+
"^@modules/(.*)$": "<rootDir>/src/modules/$1",
|
|
70
|
+
"^@tests/(.*)$": "<rootDir>/src/tests/$1",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/** The test folders under `src/tests/` that a project other than `unit` owns (owner test layout 2026-09-30). */
|
|
74
|
+
const TEST_KIND_FOLDERS = Object.freeze(["world", "integration", "e2e", "contract"])
|
|
75
|
+
const underTests = (folder) => String.raw`[\\/]src[\\/]tests[\\/]` + folder + String.raw`[\\/]`
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The whole jest config of a Nest repository: projects `unit` (colocated `<name>.spec.ts`), `integration`
|
|
79
|
+
* (`src/tests/integration/<capability>/<name>.integration-spec.ts`), `e2e` (`src/tests/e2e/<area>/<name>.e2e-spec.ts`) and
|
|
80
|
+
* `contract` (`src/tests/contract/<provider>/<name>.contract-spec.ts`). It takes no options: the repository's `jest.config.js` is a managed file
|
|
81
|
+
* (R05), rendered by `hfs sync` as `module.exports = require("@starci/jest-preset").starciJestConfig()`.
|
|
46
82
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
83
|
+
* The managed scripts select one project each (`test` = unit, `test:integration`, `test:e2e`, `test:contract`); the
|
|
84
|
+
* contract project is never part of `test` or `test:e2e`, and a contract spec skips itself without sandbox config. The
|
|
85
|
+
* test tree compiles against `src/tests/tsconfig.json`, the nearest config of every file under `src/tests/`. The
|
|
86
|
+
* integration, e2e and contract projects share the world's `global-setup.ts`/`global-teardown.ts` (`src/tests/world/`) and
|
|
87
|
+
* run one worker; the unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
|
|
88
|
+
* Coverage is collected from every `*.service.ts` only, with a per-file threshold of 100 on lines, branches, functions and
|
|
89
|
+
* statements: the `test` script runs the unit project with `--coverage` and fails below it. It uses v8: istanbul instruments the helpers TypeScript emits (`__decorate`, `__param`, `__awaiter`, interop wrappers)
|
|
90
|
+
* as thousands of branches no spec can cover, while v8 measures the real source.
|
|
53
91
|
*/
|
|
54
|
-
function starciJestConfig(
|
|
55
|
-
const tsconfig = options.tsconfig ?? "tsconfig.json"
|
|
92
|
+
function starciJestConfig() {
|
|
56
93
|
const shared = {
|
|
57
94
|
preset: "ts-jest",
|
|
58
95
|
testEnvironment: "node",
|
|
59
|
-
rootDir:
|
|
60
|
-
roots:
|
|
61
|
-
moduleNameMapper:
|
|
62
|
-
transform: transform(tsconfig),
|
|
96
|
+
rootDir: ".",
|
|
97
|
+
roots: ["<rootDir>/src", "<rootDir>/apps"],
|
|
98
|
+
moduleNameMapper: { ...MODULE_NAME_MAPPER },
|
|
63
99
|
}
|
|
64
|
-
const
|
|
100
|
+
const suite = (displayName, suffix) => ({
|
|
101
|
+
...shared,
|
|
102
|
+
transform: transform("src/tests/tsconfig.json"),
|
|
103
|
+
displayName,
|
|
104
|
+
maxWorkers: 1,
|
|
105
|
+
testMatch: [`<rootDir>/src/tests/${displayName}/**/*.${suffix}.ts`],
|
|
106
|
+
// The one test world: started once per run of a project that has a test to run, torn down after it.
|
|
107
|
+
globalSetup: "<rootDir>/src/tests/world/global-setup.ts",
|
|
108
|
+
globalTeardown: "<rootDir>/src/tests/world/global-teardown.ts",
|
|
109
|
+
})
|
|
65
110
|
return {
|
|
66
111
|
testTimeout: 120_000,
|
|
112
|
+
coverageProvider: "v8",
|
|
67
113
|
collectCoverageFrom: collectCoverageFrom(),
|
|
114
|
+
// A glob key is applied to every matching file on its own: each service file must reach 100, not the average.
|
|
115
|
+
coverageThreshold: { "./src/**/*.service.ts": { ...COVERAGE_THRESHOLD } },
|
|
68
116
|
coverageDirectory: "coverage",
|
|
69
|
-
coverageReporters: ["
|
|
117
|
+
coverageReporters: ["text-summary", "text"],
|
|
70
118
|
projects: [
|
|
71
119
|
{
|
|
72
120
|
...shared,
|
|
121
|
+
transform: unitTransform(),
|
|
73
122
|
displayName: "unit",
|
|
74
123
|
clearMocks: true,
|
|
124
|
+
// The Outcome matchers (`toBeRefused`, `toSucceedWith`) exist in the unit project only.
|
|
125
|
+
setupFilesAfterEnv: [require.resolve("./matchers.cjs")],
|
|
75
126
|
testMatch: ["**/*.spec.ts"],
|
|
76
|
-
testPathIgnorePatterns: ["/node_modules/",
|
|
77
|
-
...options.unit,
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
...shared,
|
|
81
|
-
displayName: "e2e",
|
|
82
|
-
maxWorkers: 1,
|
|
83
|
-
testMatch: ["<rootDir>/src/tests/e2e/**/*.e2e-spec.ts"],
|
|
84
|
-
testPathIgnorePatterns: live ? ["/node_modules/"] : ["/node_modules/", "[\\/]src[\\/]tests[\\/]e2e[\\/]live[\\/]"],
|
|
85
|
-
...options.e2e,
|
|
127
|
+
testPathIgnorePatterns: ["/node_modules/", ...TEST_KIND_FOLDERS.map(underTests)],
|
|
86
128
|
},
|
|
129
|
+
suite("integration", "integration-spec"),
|
|
130
|
+
suite("e2e", "e2e-spec"),
|
|
131
|
+
suite("contract", "contract-spec"),
|
|
87
132
|
],
|
|
88
133
|
}
|
|
89
134
|
}
|
|
90
135
|
|
|
91
136
|
module.exports = {
|
|
92
137
|
starciJestConfig,
|
|
138
|
+
TEST_KIND_FOLDERS,
|
|
139
|
+
MODULE_NAME_MAPPER,
|
|
93
140
|
mock,
|
|
94
141
|
createMock,
|
|
142
|
+
mockEntityManager,
|
|
143
|
+
fakeTransaction,
|
|
144
|
+
FakeClock,
|
|
145
|
+
fakeCache,
|
|
146
|
+
fakeLock,
|
|
147
|
+
recordingOutbox,
|
|
148
|
+
builder,
|
|
149
|
+
fakeIds,
|
|
150
|
+
FakeIds,
|
|
95
151
|
collectCoverageFrom,
|
|
96
|
-
sonarCoverageExclusions,
|
|
97
152
|
sonarExclusions,
|
|
98
153
|
COVERAGE_SOURCES,
|
|
99
154
|
COVERAGE_EXCLUDES,
|
|
155
|
+
COVERAGE_THRESHOLD,
|
|
156
|
+
UNIT_COMPILER_OPTIONS,
|
|
100
157
|
}
|
package/index.d.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { Config } from "jest"
|
|
2
2
|
|
|
3
3
|
export { mock, createMock, MockOf } from "./mock"
|
|
4
|
+
export { mockEntityManager, fakeTransaction, MockEntityManager, FakeTransaction, EntityClass } from "./entity-manager"
|
|
5
|
+
export { fakeCache, FakeCache } from "./cache"
|
|
6
|
+
export { fakeLock, FakeLock } from "./lock"
|
|
7
|
+
export { recordingOutbox, RecordingOutbox } from "./outbox"
|
|
8
|
+
export { builder } from "./builders"
|
|
9
|
+
export { FakeClock } from "./clock"
|
|
10
|
+
export { fakeIds, FakeIds } from "./ids"
|
|
11
|
+
export { RefusalReason } from "./matchers"
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
moduleNameMapper?: Record<string, string | string[]>
|
|
9
|
-
roots?: string[]
|
|
10
|
-
unit?: Record<string, unknown>
|
|
11
|
-
e2e?: Record<string, unknown>
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export declare function starciJestConfig(options?: StarciJestOptions): Config
|
|
13
|
+
/** The whole jest config: projects `unit`, `integration`, `e2e` and `contract`. It takes no options; the repository config is a managed file. */
|
|
14
|
+
export declare function starciJestConfig(): Config
|
|
15
|
+
export declare const MODULE_NAME_MAPPER: Readonly<Record<string, string>>
|
|
15
16
|
export declare function collectCoverageFrom(): string[]
|
|
16
|
-
export declare function sonarCoverageExclusions(): string
|
|
17
17
|
export declare function sonarExclusions(): string
|
|
18
18
|
export declare const COVERAGE_SOURCES: string[]
|
|
19
19
|
export declare const COVERAGE_EXCLUDES: string[]
|
|
20
|
+
/** The compiler options the unit project overlays on the repository tsconfig so per-file 100 coverage of a decorated service is reachable. */
|
|
21
|
+
export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules: false; importHelpers: true }>
|
|
22
|
+
export declare const COVERAGE_THRESHOLD: Readonly<{ lines: 100; branches: 100; functions: 100; statements: 100 }>
|
package/lock.cjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `fakeLock(clock)` -- a behavioural in-memory double of a lease (cross-replica lock with fencing).
|
|
5
|
+
*
|
|
6
|
+
* `acquire({ name, holder, ttlMs, at? })` grants `{ name, holder, fence }` when nobody holds the name (or the previous
|
|
7
|
+
* ttl ran out on the clock) and answers `null` on contention; the fence grows with every grant of one name.
|
|
8
|
+
* `release({ grant })` lets go only when the grant is still current. A holder acquiring its own live lease renews it.
|
|
9
|
+
* Inspection: `isHeld(name)`, `holderOf(name)`, `fenceOf(name)`. Expiry is driven by the `FakeClock`; a request may carry
|
|
10
|
+
* its own `at`, which wins.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @param {{ now(): Date }} [clock] */
|
|
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) throw new Error("fakeLock: pass the FakeClock, fakeLock(clock), or an `at` on each acquire")
|
|
20
|
+
return at.getTime()
|
|
21
|
+
}
|
|
22
|
+
const liveHold = (name) => {
|
|
23
|
+
const hold = state.get(name)
|
|
24
|
+
if (hold === undefined) return undefined
|
|
25
|
+
if (clock !== undefined && clock.now().getTime() >= hold.expiresAt) {
|
|
26
|
+
state.delete(name)
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
return hold
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
async acquire(params) {
|
|
34
|
+
const at = nowOf(params)
|
|
35
|
+
const held = state.get(params.name)
|
|
36
|
+
const alive = held !== undefined && at < held.expiresAt
|
|
37
|
+
if (alive && held.holder !== params.holder) return null
|
|
38
|
+
const fence = alive ? held.fence : (fences.get(params.name) ?? 0) + 1
|
|
39
|
+
fences.set(params.name, fence)
|
|
40
|
+
state.set(params.name, { holder: params.holder, fence, expiresAt: at + params.ttlMs })
|
|
41
|
+
return { name: params.name, holder: params.holder, fence }
|
|
42
|
+
},
|
|
43
|
+
async release({ grant }) {
|
|
44
|
+
const held = state.get(grant.name)
|
|
45
|
+
if (held !== undefined && held.fence === grant.fence && held.holder === grant.holder) state.delete(grant.name)
|
|
46
|
+
},
|
|
47
|
+
/** Whether a live lease exists for `name`. */
|
|
48
|
+
isHeld: (name) => liveHold(name) !== undefined,
|
|
49
|
+
/** The holder of the live lease, or null. */
|
|
50
|
+
holderOf: (name) => liveHold(name)?.holder ?? null,
|
|
51
|
+
/** The fence of the latest grant of `name`, or null when it was never granted. */
|
|
52
|
+
fenceOf: (name) => fences.get(name) ?? null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { fakeLock }
|
package/lock.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** What a caller asks for when it wants a named lease. */
|
|
2
|
+
export interface FakeLockAcquire {
|
|
3
|
+
readonly name: string
|
|
4
|
+
readonly holder: string
|
|
5
|
+
readonly ttlMs: number
|
|
6
|
+
/** The instant of the request; defaults to the clock of the double. */
|
|
7
|
+
readonly at?: Date
|
|
8
|
+
}
|
|
9
|
+
/** A granted lease. */
|
|
10
|
+
export interface FakeLockGrant {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly holder: string
|
|
13
|
+
readonly fence: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A behavioural in-memory lease: acquire, contention, release, ttl expiry on the clock, growing fence. */
|
|
17
|
+
export interface FakeLock {
|
|
18
|
+
acquire(params: FakeLockAcquire): Promise<FakeLockGrant | null>
|
|
19
|
+
release(params: { readonly grant: FakeLockGrant }): Promise<void>
|
|
20
|
+
isHeld(name: string): boolean
|
|
21
|
+
holderOf(name: string): string | null
|
|
22
|
+
fenceOf(name: string): number | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The lock double; expiry follows `clock`, so `clock.advance(ms)` frees a lease whose ttl ran out. */
|
|
26
|
+
export declare function fakeLock(clock?: { now(): Date }): FakeLock
|
package/matchers.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Outcome matchers, registered by the `unit` project's `setupFilesAfterEnv` (nowhere else).
|
|
5
|
+
*
|
|
6
|
+
* They match the platform `Outcome<V, C>` structurally: `{ kind: "ok", value }` or `{ kind: "refused", code, params? }`.
|
|
7
|
+
* `expect(outcome).toBeRefused("STOCK_EXHAUSTED")` (or `{ code, params }`) and `expect(outcome).toSucceedWith(value)`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const isOutcome = (received) => typeof received === "object" && received !== null && (received.kind === "ok" || received.kind === "refused")
|
|
11
|
+
|
|
12
|
+
const notOutcome = (utils, received) => `expected an Outcome ({ kind: "ok" | "refused" }), received ${utils.printReceived(received)}`
|
|
13
|
+
|
|
14
|
+
const matchers = {
|
|
15
|
+
toBeRefused(received, reason) {
|
|
16
|
+
if (!isOutcome(received)) return { pass: false, message: () => notOutcome(this.utils, received) }
|
|
17
|
+
const expected = typeof reason === "string" ? { code: reason } : reason
|
|
18
|
+
const pass =
|
|
19
|
+
received.kind === "refused" &&
|
|
20
|
+
received.code === expected.code &&
|
|
21
|
+
(!("params" in expected) || this.equals(received.params, expected.params))
|
|
22
|
+
return {
|
|
23
|
+
pass,
|
|
24
|
+
message: () =>
|
|
25
|
+
pass
|
|
26
|
+
? `expected the outcome not to be refused with ${this.utils.printExpected(expected)}`
|
|
27
|
+
: `expected the outcome to be refused with ${this.utils.printExpected(expected)}, received ${this.utils.printReceived(received)}`,
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
toSucceedWith(received, value) {
|
|
31
|
+
if (!isOutcome(received)) return { pass: false, message: () => notOutcome(this.utils, received) }
|
|
32
|
+
const pass = received.kind === "ok" && this.equals(received.value, value)
|
|
33
|
+
return {
|
|
34
|
+
pass,
|
|
35
|
+
message: () =>
|
|
36
|
+
pass
|
|
37
|
+
? `expected the outcome not to succeed with ${this.utils.printExpected(value)}`
|
|
38
|
+
: `expected the outcome to succeed with ${this.utils.printExpected(value)}, received ${this.utils.printReceived(received)}`,
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof expect !== "undefined" && typeof expect.extend === "function") expect.extend(matchers)
|
|
44
|
+
|
|
45
|
+
module.exports = { matchers }
|
package/matchers.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
|
|
3
|
+
/** The refusal a matcher expects: a capability code, or a code with the parameters of its display text. */
|
|
4
|
+
export type RefusalReason = string | { readonly code: string; readonly params?: Record<string, unknown> }
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
namespace jest {
|
|
8
|
+
interface Matchers<R> {
|
|
9
|
+
/** The received `Outcome` is `{ kind: "refused" }` with this code (and these params, when given). */
|
|
10
|
+
toBeRefused(reason: RefusalReason): R
|
|
11
|
+
/** The received `Outcome` is `{ kind: "ok" }` whose value equals `value` (recursive equality). */
|
|
12
|
+
toSucceedWith(value: unknown): R
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export declare const matchers: Record<"toBeRefused" | "toSucceedWith", (this: jest.MatcherContext, received: unknown, expected: unknown) => jest.CustomMatcherResult>
|
package/mock.cjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* type-checks against `Repo["find"]`. Values passed in `overrides` win over the generated functions and may be
|
|
9
9
|
* partial: the return type is still `T`.
|
|
10
10
|
*
|
|
11
|
-
* `createMock` takes the function factory as an argument so the helper has no hard
|
|
12
|
-
* `jest`; `mock` binds it to `jest.fn`.
|
|
11
|
+
* `createMock` takes the function factory as an argument (called with the property name) so the helper has no hard
|
|
12
|
+
* dependency on a global `jest`; `mock` binds it to `jest.fn`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
// Probed by promise resolution, jest matchers and serializers: they must read as absent, not as a mock function.
|
|
@@ -24,7 +24,7 @@ function createMock(makeFn) {
|
|
|
24
24
|
if (Object.hasOwn(target, property)) return Reflect.get(target, property, receiver)
|
|
25
25
|
if (typeof property === "symbol" || property in Object.prototype) return Reflect.get(target, property, receiver)
|
|
26
26
|
if (ABSENT.has(property)) return undefined
|
|
27
|
-
if (!own.has(property)) own.set(property, makeFn())
|
|
27
|
+
if (!own.has(property)) own.set(property, makeFn(property))
|
|
28
28
|
return own.get(property)
|
|
29
29
|
},
|
|
30
30
|
set(target, property, value) {
|
|
@@ -41,13 +41,13 @@ function createMock(makeFn) {
|
|
|
41
41
|
|
|
42
42
|
// jest-runtime hands every module it loads, this one included, a module-scoped `jest` object; it is not a property
|
|
43
43
|
// of globalThis, so it is read as a free identifier. @jest/globals is the fallback for a runner that does not.
|
|
44
|
-
function jestFn() {
|
|
45
|
-
if (typeof jest !== "undefined" && typeof jest.fn === "function") return jest.fn()
|
|
44
|
+
function jestFn(implementation) {
|
|
45
|
+
if (typeof jest !== "undefined" && typeof jest.fn === "function") return jest.fn(implementation)
|
|
46
46
|
try {
|
|
47
|
-
return require("@jest/globals").jest.fn()
|
|
47
|
+
return require("@jest/globals").jest.fn(implementation)
|
|
48
48
|
} catch {
|
|
49
49
|
throw new Error("@starci/jest-preset mock<T>() needs the jest runtime; call it from a jest test")
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
module.exports = { mock: createMock(jestFn), createMock }
|
|
53
|
+
module.exports = { mock: createMock(() => jestFn()), createMock, jestFn }
|
package/outbox.cjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `recordingOutbox()` -- an outbox double that records what a service writes, so a spec asserts on `outbox.messages`.
|
|
5
|
+
*
|
|
6
|
+
* `enqueue(manager, message)` records the message with the manager it was written through. `messages` is what the store
|
|
7
|
+
* would hold: one message per (queue, eventId), like the real unique key, so a duplicate write keeps one. `writes` is every
|
|
8
|
+
* call, duplicates included, and `entries` adds `inTransaction`: whether the manager was the view a `fakeTransaction` body
|
|
9
|
+
* received, so a spec proves a message is written in the transaction of the change that caused it.
|
|
10
|
+
*
|
|
11
|
+
* The claim side scripts what a worker's claim returns. `queueRecords(...records)` fills the backlog; `claimDue(params)` hands
|
|
12
|
+
* out, in order, up to `params.limit` backlog records whose queue is in `params.queues` and removes them, so an empty backlog is
|
|
13
|
+
* an empty claim and queueing the same record twice is a duplicate delivery. `claims` keeps every claim's params, `completed`,
|
|
14
|
+
* `retried` and `buried` keep what the worker reported, and `failNext(operation, error)` makes the next call of that operation
|
|
15
|
+
* reject once, so a spec drives the failure path.
|
|
16
|
+
*/
|
|
17
|
+
const { isTransactionManager } = require("./entity-manager.cjs")
|
|
18
|
+
|
|
19
|
+
function recordingOutbox() {
|
|
20
|
+
const written = []
|
|
21
|
+
const backlog = []
|
|
22
|
+
const claims = []
|
|
23
|
+
const completed = []
|
|
24
|
+
const retried = []
|
|
25
|
+
const buried = []
|
|
26
|
+
const failures = new Map()
|
|
27
|
+
const failIfScripted = (operation) => {
|
|
28
|
+
if (!failures.has(operation)) return
|
|
29
|
+
const error = failures.get(operation)
|
|
30
|
+
failures.delete(operation)
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
async enqueue(manager, message) {
|
|
35
|
+
failIfScripted("enqueue")
|
|
36
|
+
written.push({ message, manager, inTransaction: isTransactionManager(manager) })
|
|
37
|
+
},
|
|
38
|
+
/** The messages the store would hold: the first write of each (queue, eventId). */
|
|
39
|
+
get messages() {
|
|
40
|
+
const seen = new Set()
|
|
41
|
+
return written
|
|
42
|
+
.filter(({ message }) => {
|
|
43
|
+
const key = `${message.queue}\u0000${message.eventId}`
|
|
44
|
+
if (seen.has(key)) return false
|
|
45
|
+
seen.add(key)
|
|
46
|
+
return true
|
|
47
|
+
})
|
|
48
|
+
.map(({ message }) => message)
|
|
49
|
+
},
|
|
50
|
+
/** Every write, in order: `{ message, manager, inTransaction }`. */
|
|
51
|
+
get entries() {
|
|
52
|
+
return [...written]
|
|
53
|
+
},
|
|
54
|
+
/** Every message written, duplicates included. */
|
|
55
|
+
get writes() {
|
|
56
|
+
return written.map(({ message }) => message)
|
|
57
|
+
},
|
|
58
|
+
/** True when something was written and every write went through a transaction manager. */
|
|
59
|
+
get allInTransaction() {
|
|
60
|
+
return written.length > 0 && written.every((entry) => entry.inTransaction)
|
|
61
|
+
},
|
|
62
|
+
/** The stored messages of one queue. */
|
|
63
|
+
messagesOf(queue) {
|
|
64
|
+
return this.messages.filter((message) => message.queue === queue)
|
|
65
|
+
},
|
|
66
|
+
/** Adds records to the backlog `claimDue` hands out. */
|
|
67
|
+
queueRecords(...records) {
|
|
68
|
+
backlog.push(...records)
|
|
69
|
+
},
|
|
70
|
+
async claimDue(params) {
|
|
71
|
+
claims.push(params)
|
|
72
|
+
failIfScripted("claimDue")
|
|
73
|
+
const claimed = []
|
|
74
|
+
for (const record of [...backlog]) {
|
|
75
|
+
if (claimed.length >= params.limit) break
|
|
76
|
+
if (!params.queues.includes(record.queue)) continue
|
|
77
|
+
claimed.push(record)
|
|
78
|
+
backlog.splice(backlog.indexOf(record), 1)
|
|
79
|
+
}
|
|
80
|
+
return claimed
|
|
81
|
+
},
|
|
82
|
+
async complete(id) {
|
|
83
|
+
failIfScripted("complete")
|
|
84
|
+
completed.push(id)
|
|
85
|
+
},
|
|
86
|
+
async retry(params) {
|
|
87
|
+
failIfScripted("retry")
|
|
88
|
+
retried.push(params)
|
|
89
|
+
},
|
|
90
|
+
async bury(params) {
|
|
91
|
+
failIfScripted("bury")
|
|
92
|
+
buried.push(params)
|
|
93
|
+
},
|
|
94
|
+
/** Makes the next call of `operation` reject with `error`, once. */
|
|
95
|
+
failNext(operation, error) {
|
|
96
|
+
failures.set(operation, error)
|
|
97
|
+
},
|
|
98
|
+
/** The params of every `claimDue` call, in order. */
|
|
99
|
+
get claims() {
|
|
100
|
+
return [...claims]
|
|
101
|
+
},
|
|
102
|
+
/** The ids passed to `complete`, in order. */
|
|
103
|
+
get completed() {
|
|
104
|
+
return [...completed]
|
|
105
|
+
},
|
|
106
|
+
/** The params of every `retry`, in order. */
|
|
107
|
+
get retried() {
|
|
108
|
+
return [...retried]
|
|
109
|
+
},
|
|
110
|
+
/** The params of every `bury`, in order. */
|
|
111
|
+
get buried() {
|
|
112
|
+
return [...buried]
|
|
113
|
+
},
|
|
114
|
+
/** The records still waiting for a claim. */
|
|
115
|
+
get backlog() {
|
|
116
|
+
return [...backlog]
|
|
117
|
+
},
|
|
118
|
+
clear() {
|
|
119
|
+
for (const list of [written, backlog, claims, completed, retried, buried]) list.length = 0
|
|
120
|
+
failures.clear()
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { recordingOutbox }
|
package/outbox.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** A message as the outbox port takes it. */
|
|
2
|
+
export interface RecordedMessage {
|
|
3
|
+
readonly queue: string
|
|
4
|
+
readonly eventId: string
|
|
5
|
+
readonly payload: object
|
|
6
|
+
readonly availableAt?: Date
|
|
7
|
+
}
|
|
8
|
+
/** One recorded write. */
|
|
9
|
+
export interface OutboxWrite<M extends RecordedMessage = RecordedMessage> {
|
|
10
|
+
readonly message: M
|
|
11
|
+
readonly manager: unknown
|
|
12
|
+
/** Whether the manager was the view a `fakeTransaction` body received. */
|
|
13
|
+
readonly inTransaction: boolean
|
|
14
|
+
}
|
|
15
|
+
/** A message a worker has claimed, as the outbox port returns it. */
|
|
16
|
+
export interface ClaimedRecord {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly queue: string
|
|
19
|
+
readonly eventId: string
|
|
20
|
+
readonly payload: unknown
|
|
21
|
+
readonly attempts: number
|
|
22
|
+
}
|
|
23
|
+
/** What a worker asks for when it claims due messages. */
|
|
24
|
+
export interface ClaimParams {
|
|
25
|
+
readonly at: Date
|
|
26
|
+
readonly queues: ReadonlyArray<string>
|
|
27
|
+
readonly limit: number
|
|
28
|
+
readonly visibilityMs: number
|
|
29
|
+
}
|
|
30
|
+
/** What a worker reports when a delivery failed and should be tried again. */
|
|
31
|
+
export interface RetryParams {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly at: Date
|
|
34
|
+
readonly error: string
|
|
35
|
+
}
|
|
36
|
+
/** What a worker reports when a message will not be delivered any more. */
|
|
37
|
+
export interface BuryParams {
|
|
38
|
+
readonly id: string
|
|
39
|
+
readonly error: string
|
|
40
|
+
}
|
|
41
|
+
/** The outbox operations `failNext` can make reject. */
|
|
42
|
+
export type OutboxOperation = "enqueue" | "claimDue" | "complete" | "retry" | "bury"
|
|
43
|
+
|
|
44
|
+
/** An outbox double that records writes and scripts the claim side. */
|
|
45
|
+
export interface RecordingOutbox<M extends RecordedMessage = RecordedMessage, R extends ClaimedRecord = ClaimedRecord> {
|
|
46
|
+
enqueue(manager: unknown, message: M): Promise<void>
|
|
47
|
+
/** What the store would hold: the first write of each (queue, eventId). */
|
|
48
|
+
readonly messages: ReadonlyArray<M>
|
|
49
|
+
/** Every write with its manager, duplicates included. */
|
|
50
|
+
readonly entries: ReadonlyArray<OutboxWrite<M>>
|
|
51
|
+
/** Every message written, duplicates included. */
|
|
52
|
+
readonly writes: ReadonlyArray<M>
|
|
53
|
+
/** True when something was written and every write went through a `fakeTransaction` manager. */
|
|
54
|
+
readonly allInTransaction: boolean
|
|
55
|
+
messagesOf(queue: string): ReadonlyArray<M>
|
|
56
|
+
/** Adds records to the backlog `claimDue` hands out; the same record twice is a duplicate delivery. */
|
|
57
|
+
queueRecords(...records: ReadonlyArray<R>): void
|
|
58
|
+
/** Hands out, in order, up to `limit` backlog records of the asked queues and removes them; an empty backlog is an empty claim. */
|
|
59
|
+
claimDue(params: ClaimParams): Promise<Array<R>>
|
|
60
|
+
complete(id: string): Promise<void>
|
|
61
|
+
retry(params: RetryParams): Promise<void>
|
|
62
|
+
bury(params: BuryParams): Promise<void>
|
|
63
|
+
/** Makes the next call of `operation` reject with `error`, once. */
|
|
64
|
+
failNext(operation: OutboxOperation, error: Error): void
|
|
65
|
+
/** The params of every `claimDue` call. */
|
|
66
|
+
readonly claims: ReadonlyArray<ClaimParams>
|
|
67
|
+
/** The ids passed to `complete`. */
|
|
68
|
+
readonly completed: ReadonlyArray<string>
|
|
69
|
+
/** The params of every `retry`. */
|
|
70
|
+
readonly retried: ReadonlyArray<RetryParams>
|
|
71
|
+
/** The params of every `bury`. */
|
|
72
|
+
readonly buried: ReadonlyArray<BuryParams>
|
|
73
|
+
/** The records still waiting for a claim. */
|
|
74
|
+
readonly backlog: ReadonlyArray<R>
|
|
75
|
+
clear(): void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The outbox double. */
|
|
79
|
+
export declare function recordingOutbox<M extends RecordedMessage = RecordedMessage, R extends ClaimedRecord = ClaimedRecord>(): RecordingOutbox<M, R>
|