@starci/jest-preset 2.2.3 → 2.2.4
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 +18 -0
- package/README.md +39 -27
- package/entity-manager.cjs +1 -1
- package/entity-manager.d.ts +1 -1
- package/event-bus.cjs +100 -0
- package/event-bus.d.ts +54 -0
- package/inbox.cjs +71 -0
- package/inbox.d.ts +29 -0
- package/index.cjs +97 -21
- package/index.d.ts +19 -5
- package/package.json +13 -5
- package/queue.cjs +51 -0
- package/queue.d.ts +26 -0
- package/world-runner.cjs +98 -25
- package/outbox.cjs +0 -125
- package/outbox.d.ts +0 -79
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Breaking (contract change `coverage-modules-logic`): `starciJestConfig({ coverage: { roots, roles, excludes } })` takes the coverage scope the managed `be/jest.config.js` renders (`hfs sync`, from the `coverage` field of the slot manifest and `ruleParams.be.logicRoles`, through `scripts/hfs/coverage-scope.mjs`), and refuses to run without it. `COVERAGE_SOURCES` is deleted: per-file 100 now holds on every logic role (service, policy, projection, guard, mapper, client, filter, interceptor, processor, step, saga, saga-step, compensation, consumer, webhook) inside the measured roots (the logic of `be/src/modules`), never on `be/src/features` (thin by R203). New exports `rootGlob` and `StarciCoverageScope`; `collectCoverageFrom(coverage)` and `hasCoverageSubjects(root, glob)` take a scope and a glob with `**` and `{a,b}`.
|
|
6
|
+
- New: `fakeInbox()` (also `@starci/jest-preset/inbox`), the twin of `recordingOutbox()` for the delivery side: `claim` answers true for the first call of a (source, eventId) pair and false afterwards, `release` gives the claim back, `seen` marks a redelivery, and `claims`, `claimed`, `released`, `failNext` and `clear` let a spec assert what a consumer or a signed webhook did. A spec no longer stubs `mock<Inbox>()` by hand.
|
|
7
|
+
|
|
8
|
+
## 2.2.4 - 2026-10-02
|
|
9
|
+
|
|
10
|
+
- Changed: the world runner runs up to `min(--maxWorkers, slots)` files of the integration, e2e and contract projects at once,
|
|
11
|
+
each still in a fresh process of its own (never in band), each bound to one data slot of `@starci/test-world` 1.1.0: the
|
|
12
|
+
file's process is forked with `STARCI_TEST_WORLD_SLOT=<k>`, and no two files ever hold one slot at the same time. The slot
|
|
13
|
+
count is the one the world's globalSetup published in its state file. This lifts the 2.2.0 limitation "one file at a time".
|
|
14
|
+
- Changed: the runner reads test-world state protocol 2 only. A missing state file or another protocol fails the run with
|
|
15
|
+
`JEST_PRESET_WORLD_PAIR_MISMATCH`, naming this preset, the test-world that wrote the file and the fix (pin both together per
|
|
16
|
+
knowledge/hfs/canon-pins.yaml); there is no fallback mode.
|
|
17
|
+
## Unreleased
|
|
18
|
+
|
|
19
|
+
- Removed (breaking): `recordingOutbox()` and the `./outbox` entry, superseded by the event bus and the queue outbox. Added `recordingEventBus()` (the `EventBus` port: `publish(event, tx)`, scripted pending retries, dead letters and one-shot failures) and `recordingQueueOutbox()` (the `QueueOutbox` port: `write(tx, queue, payload)`), each recording whether `tx` was a `fakeTransaction` manager, and the `./event-bus` and `./queue` entries. Contract change `patterns-event-bus`.
|
|
20
|
+
|
|
3
21
|
## 2.2.2 - 2026-10-01
|
|
4
22
|
|
|
5
23
|
- Changed: the coverage reporters are `text-summary`, `text` and `lcov`. The unit run writes `coverage/lcov.info`, which Sonar (`sonar.javascript.lcov.reportPaths`) and Codecov import with the scope `hfs sync` renders from `COVERAGE_SOURCES` (services only). Coverage is still collected from `src/**/*.service.ts` only, with the per-file threshold of 100. Contract change `sonar-services-coverage`.
|
package/README.md
CHANGED
|
@@ -1,47 +1,54 @@
|
|
|
1
1
|
# @starci/jest-preset
|
|
2
2
|
|
|
3
|
-
The jest config of a Nest repository and the one shared unit-test double kit (`mock<T>()`, `mockEntityManager`, `fakeTransaction`, `fakeCache`, `fakeLock`, `
|
|
3
|
+
The jest config of a Nest repository and the one shared unit-test double kit (`mock<T>()`, `mockEntityManager`, `fakeTransaction`, `fakeCache`, `fakeLock`, `recordingEventBus`, `recordingQueueOutbox`, `fakeInbox`, `builder`, `FakeClock`, `fakeIds` and the Outcome matchers). Install it from the npm registry at the exact version in [`knowledge/hfs/canon-pins.yaml`](../../knowledge/hfs/canon-pins.yaml) (see [`packages/README.md`](../README.md)).
|
|
4
4
|
|
|
5
5
|
```js
|
|
6
|
-
// jest.config.js (a managed file: `
|
|
7
|
-
module.exports = require("@starci/jest-preset").starciJestConfig(
|
|
6
|
+
// jest.config.js (a managed file: `starci app sync` renders it, `starci app check` compares it; do not edit)
|
|
7
|
+
module.exports = require("@starci/jest-preset").starciJestConfig({
|
|
8
|
+
coverage: { roots, roles, excludes },
|
|
9
|
+
})
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
The only option is `coverage`: the measured scope `starci app sync` derives once from the slot manifest
|
|
13
|
+
(`scripts/hfs/coverage-scope.mjs`, R204 `HFS_COVERAGE_SCOPE_DRIFT`) — the measured roots, the roles a measured file carries
|
|
14
|
+
and the exclusions. A repository has no other jest setting to tune.
|
|
11
15
|
|
|
12
16
|
## What it sets
|
|
13
17
|
|
|
14
|
-
- **Four projects, one per test kind**: `unit` (`**/*.spec.ts`, which by the unit standard means one `<name>.
|
|
18
|
+
- **Four projects, one per test kind**: `unit` (`**/*.spec.ts`, which by the unit standard means one `<name>.<role>.spec.ts` beside each spec-required subject — a `*.service.ts`, a `*.cli.ts` of `src/features/cli` — and beside any other measured logic file that chooses one; `clearMocks`; the Outcome matchers are installed through `setupFilesAfterEnv`; ignores
|
|
15
19
|
`src/tests/{world,integration,e2e,contract}/`), `integration` (`src/tests/integration/**/*.integration-spec.ts`), `e2e`
|
|
16
20
|
(`src/tests/e2e/**/*.e2e-spec.ts`) and `contract` (`src/tests/contract/**/*.contract-spec.ts`). The last three compile against
|
|
17
21
|
`src/tests/tsconfig.json`, start the one test world through `globalSetup`/`globalTeardown`
|
|
18
22
|
(`src/tests/world/global-setup.ts`, `global-teardown.ts`) and run on the world runner (`world-runner.cjs`, `WORLD_RUNNER`): every
|
|
19
23
|
spec file in a worker process of its own and never in band, so nothing the globalSetup or another file left on a process global
|
|
20
|
-
(a framework registry such as `@nestjs/graphql`'s type metadata) reaches the file; and
|
|
21
|
-
|
|
24
|
+
(a framework registry such as `@nestjs/graphql`'s type metadata) reaches the file; and up to `min(--maxWorkers, slots)` files at
|
|
25
|
+
once, each bound to its own data slot of `@starci/test-world` 1.1.0 (`STARCI_TEST_WORLD_SLOT`), never two files on one slot,
|
|
26
|
+
because the test world resets a slot's data when a file boots. The runner reads test-world state protocol 2 only: pin
|
|
27
|
+
the two packages together (canon-pins); another protocol fails with `JEST_PRESET_WORLD_PAIR_MISMATCH`. Only the unit project has a `setupFilesAfterEnv`. The managed scripts select one
|
|
22
28
|
project each: `test`, `test:integration`, `test:e2e`, `test:contract`; a contract spec skips itself without sandbox config.
|
|
23
29
|
- **The three path aliases** `@features/*`, `@modules/*`, `@tests/*` (`MODULE_NAME_MAPPER`), the same ones the managed `tsconfig.json` declares.
|
|
24
30
|
- **ts-jest with `diagnostics: false`** in every project. `isolatedModules: true` comes from `@starci/tsconfig` (ts-jest 29.4 reads it
|
|
25
31
|
there; its own option is deprecated). Types are checked once, by `typecheck` (unit specs included) and `typecheck:tests`
|
|
26
32
|
(`src/tests/tsconfig.json`), and `test:integration`/`test:e2e`/`test:contract` run `typecheck:tests` first, so no world spec
|
|
27
33
|
runs on code that does not type-check.
|
|
28
|
-
- **Coverage of
|
|
29
|
-
|
|
30
|
-
a glob key
|
|
34
|
+
- **Coverage of the measured logic, per-file 100.** `collectCoverageFrom` is one glob per `coverage.roots` entry covering `<root>/**/*.{<roles>}.ts`
|
|
35
|
+
(the `ruleParams.be.logicRoles` of the rendered scope), minus its `excludes`, `dist` and `coverage`, and `coverageThreshold` holds
|
|
36
|
+
`{ lines: 100, branches: 100, functions: 100, statements: 100 }` on each of those globs — a glob key applies to each file on its own, so every file of a
|
|
37
|
+
`ruleParams.be.logicRoles` role inside a measured slot must reach 100 on every metric (an average never passes). The
|
|
31
38
|
managed `test` script runs the unit project with `--coverage` and fails below it. Coverage is measured by v8, which counts the
|
|
32
39
|
real source. The unit project overlays `isolatedModules: false` and `importHelpers: true` on the repository tsconfig (ts-jest merges an
|
|
33
40
|
inline `tsconfig` object over the `tsconfig.json` it finds): with `isolatedModules` on, TypeScript emits an unreachable
|
|
34
41
|
`typeof Dep !== "undefined" ? Dep : Object` guard per class-typed constructor parameter, and inlined decorator helpers carry branches
|
|
35
42
|
of their own, so 100 would be unreachable. The repository lists `tslib` in its devDependencies (`UNIT_COMPILER_OPTIONS`). Reporters are `text-summary`,
|
|
36
43
|
`text` and `lcov`: the run writes `coverage/lcov.info` under the repository (be/) root.
|
|
37
|
-
- **Sonar and Codecov import that lcov, scoped to the
|
|
38
|
-
status paths
|
|
44
|
+
- **Sonar and Codecov import that lcov, scoped to the same measured files.** `starci app sync` renders `sonar.coverage.exclusions` (the complement of the measured files: SonarQube has no coverage inclusions) and the `codecov.yml`
|
|
45
|
+
status paths and components from the one derivation (`scripts/hfs/coverage-scope.mjs`), so jest, Sonar and Codecov measure the same files;
|
|
39
46
|
the Sonar gate holds coverage at 100 overall, on new code and per file. `sonarExclusions()` still renders `sonar.exclusions`
|
|
40
47
|
(`**/*.spec.ts,**/*.e2e-spec.ts,**/dist/**,**/coverage/**`). There is no coverage exclusion list.
|
|
41
48
|
|
|
42
49
|
## The unit standard in one page
|
|
43
50
|
|
|
44
|
-
|
|
51
|
+
Every spec-required file is unit-tested beside its subject — a `*.service.ts`, a cli command of `src/features/cli` — through `Test.createTestingModule`, with the doubles of this package and nothing else, and any other measured logic file may carry the same spec beside it:
|
|
45
52
|
|
|
46
53
|
```ts
|
|
47
54
|
import { Test } from "@nestjs/testing"
|
|
@@ -62,7 +69,7 @@ const service = moduleRef.get(CommissionService)
|
|
|
62
69
|
No `new` of the service, no `imports`, no `overrideProvider`, no `jest.mock`, no casts, no `Date.now()` and no `process.env`. The
|
|
63
70
|
full rules and the dependency-to-double-to-cases table are `knowledge/patterns/be/test.yaml` (BE-TEST-1 to BE-TEST-15). Every
|
|
64
71
|
export below is on the package root (`@starci/jest-preset`); the sub-paths `./mock`, `./clock`, `./entity-manager`, `./ids`,
|
|
65
|
-
`./matchers`, `./cache`, `./lock`, `./
|
|
72
|
+
`./matchers`, `./cache`, `./lock`, `./event-bus`, `./queue`, `./inbox` and `./builders` exist as well.
|
|
66
73
|
|
|
67
74
|
## `mockEntityManager` and `fakeTransaction`
|
|
68
75
|
|
|
@@ -95,7 +102,7 @@ The stubbable methods are `findOne`, `findOneBy`, `findOneOrFail`, `findOneByOrF
|
|
|
95
102
|
`delete`, `softDelete`, `restore`, `increment`, `decrement` and `query`. `fakeTransaction(em)` returns `{ em, outcomes, commits,
|
|
96
103
|
rollbacks, committedWrites, rolledBackWrites }`.
|
|
97
104
|
|
|
98
|
-
## `fakeCache(clock)`, `fakeLock(clock)` and `
|
|
105
|
+
## `fakeCache(clock)`, `fakeLock(clock)`, `recordingEventBus()` and `recordingQueueOutbox()`
|
|
99
106
|
|
|
100
107
|
```ts
|
|
101
108
|
const clock = new FakeClock("2026-01-01T00:00:00.000Z")
|
|
@@ -114,18 +121,23 @@ const grant = await lock.acquire({ name: "writer", holder: "a", ttlMs: 30_000 })
|
|
|
114
121
|
lock.isHeld("writer"); lock.holderOf("writer"); lock.fenceOf("writer")
|
|
115
122
|
await lock.release({ grant })
|
|
116
123
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const bus = recordingEventBus() // publish(event, tx): the EventBus port
|
|
125
|
+
bus.events // what the outbox would hold: the first publication of each (event name, event id)
|
|
126
|
+
bus.writes // every event published, duplicates included
|
|
127
|
+
bus.entries // every publication with its tx and `inTransaction`
|
|
128
|
+
bus.allInTransaction; bus.eventsOf(OrderPlacedEvent); bus.clear()
|
|
129
|
+
bus.setPendingRetries(2) // what pendingRetries(eventClass) answers
|
|
130
|
+
bus.queueDeadLetters(letter) // what deadLetters(eventClass) hands out; bus.requeued keeps requeue(id) calls
|
|
131
|
+
bus.failNext("publish", new Error("down")) // the next call of that operation rejects once
|
|
132
|
+
|
|
133
|
+
const queueOutbox = recordingQueueOutbox() // write(tx, queue, payload): the QueueOutbox port
|
|
134
|
+
queueOutbox.jobs; queueOutbox.jobsOf("mail"); queueOutbox.entries; queueOutbox.allInTransaction
|
|
135
|
+
|
|
136
|
+
const inbox = fakeInbox() // the delivery side, the twin of the recording outboxes
|
|
137
|
+
await inbox.claim("sepay", "e-1") // true the first time, false for the same pair until it is released
|
|
138
|
+
await inbox.release("sepay", "e-1") // gives the claim back: the next claim answers true
|
|
139
|
+
inbox.seen("sepay", "e-2") // a redelivery: the next claim of the pair answers false
|
|
140
|
+
inbox.claims; inbox.claimed; inbox.released; inbox.failNext("claim", new Error("db down")); inbox.clear()
|
|
129
141
|
```
|
|
130
142
|
|
|
131
143
|
## `builder`, `fakeIds` and the Outcome matchers
|
package/entity-manager.cjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
const { createMock, jestFn } = require("./mock.cjs")
|
|
21
21
|
|
|
22
|
-
/** The manager views handed to a transaction body; `
|
|
22
|
+
/** The manager views handed to a transaction body; `recordingEventBus` and `recordingQueueOutbox` ask whether a manager is one of them. */
|
|
23
23
|
const TRANSACTION_MANAGERS = new WeakSet()
|
|
24
24
|
|
|
25
25
|
/** Methods that change data; a write made inside a transaction is recorded on it. */
|
package/entity-manager.d.ts
CHANGED
|
@@ -93,7 +93,7 @@ export interface FakeTransaction<M extends MockEntityManager = MockEntityManager
|
|
|
93
93
|
/** Makes `em.transaction(work)` run `work` with a scoped view of the double and record commit, rollback and writes. */
|
|
94
94
|
export declare function fakeTransaction<M extends MockEntityManager = MockEntityManager>(em?: M): FakeTransaction<M>
|
|
95
95
|
|
|
96
|
-
/** Whether `manager` is the view a `fakeTransaction` body received (used by `
|
|
96
|
+
/** Whether `manager` is the view a `fakeTransaction` body received (used by `recordingEventBus` and `recordingQueueOutbox`). */
|
|
97
97
|
export declare function isTransactionManager(manager: unknown): boolean
|
|
98
98
|
|
|
99
99
|
/** The kit over a function factory, for callers that bring their own (used by the package spec). */
|
package/event-bus.cjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `recordingEventBus()` -- an `EventBus` double that records what a domain service publishes, so a spec asserts on `bus.events`.
|
|
5
|
+
*
|
|
6
|
+
* `publish(event, tx)` records the event with the manager it was written through. `events` is what the outbox would hold: one
|
|
7
|
+
* event per (event name, event id), like the real unique key, so a duplicate publication keeps one. `writes` is every call, duplicates
|
|
8
|
+
* included, and `entries` adds `inTransaction`: whether `tx` was the view a `fakeTransaction` body received, so a spec proves an event
|
|
9
|
+
* is published in the transaction of the change that caused it. The name of an event is the static `eventName` of its class.
|
|
10
|
+
*
|
|
11
|
+
* The read side is scripted: `setPendingRetries(count)` sets what `pendingRetries(eventClass)` answers, `deadLetters(eventClass)`
|
|
12
|
+
* returns what `queueDeadLetters(...letters)` filled, `requeue(id)` records the id, and `failNext(operation, error)` makes the next
|
|
13
|
+
* call of that operation reject once, so a spec drives the failure path.
|
|
14
|
+
*/
|
|
15
|
+
const { isTransactionManager } = require("./entity-manager.cjs")
|
|
16
|
+
|
|
17
|
+
const nameOf = (event) => event.constructor.eventName
|
|
18
|
+
|
|
19
|
+
function recordingEventBus() {
|
|
20
|
+
const written = []
|
|
21
|
+
const letters = []
|
|
22
|
+
const requeued = []
|
|
23
|
+
const failures = new Map()
|
|
24
|
+
let pending = 0
|
|
25
|
+
const failIfScripted = (operation) => {
|
|
26
|
+
if (!failures.has(operation)) return
|
|
27
|
+
const error = failures.get(operation)
|
|
28
|
+
failures.delete(operation)
|
|
29
|
+
throw error
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
async publish(event, tx) {
|
|
33
|
+
failIfScripted("publish")
|
|
34
|
+
written.push({ event, tx, inTransaction: isTransactionManager(tx) })
|
|
35
|
+
},
|
|
36
|
+
async pendingRetries() {
|
|
37
|
+
failIfScripted("pendingRetries")
|
|
38
|
+
return pending
|
|
39
|
+
},
|
|
40
|
+
async deadLetters(eventClass) {
|
|
41
|
+
failIfScripted("deadLetters")
|
|
42
|
+
return letters.filter((letter) => letter.eventName === eventClass.eventName)
|
|
43
|
+
},
|
|
44
|
+
async requeue(id) {
|
|
45
|
+
failIfScripted("requeue")
|
|
46
|
+
requeued.push(id)
|
|
47
|
+
},
|
|
48
|
+
/** The events the outbox would hold: the first publication of each (event name, event id). */
|
|
49
|
+
get events() {
|
|
50
|
+
const seen = new Set()
|
|
51
|
+
return written
|
|
52
|
+
.filter(({ event }) => {
|
|
53
|
+
const key = `${nameOf(event)}\u0000${event.eventId}`
|
|
54
|
+
if (seen.has(key)) return false
|
|
55
|
+
seen.add(key)
|
|
56
|
+
return true
|
|
57
|
+
})
|
|
58
|
+
.map(({ event }) => event)
|
|
59
|
+
},
|
|
60
|
+
/** Every publication, in order: `{ event, tx, inTransaction }`. */
|
|
61
|
+
get entries() {
|
|
62
|
+
return [...written]
|
|
63
|
+
},
|
|
64
|
+
/** Every event published, duplicates included. */
|
|
65
|
+
get writes() {
|
|
66
|
+
return written.map(({ event }) => event)
|
|
67
|
+
},
|
|
68
|
+
/** True when something was published and every publication went through a transaction manager. */
|
|
69
|
+
get allInTransaction() {
|
|
70
|
+
return written.length > 0 && written.every((entry) => entry.inTransaction)
|
|
71
|
+
},
|
|
72
|
+
/** The stored events of one class. */
|
|
73
|
+
eventsOf(eventClass) {
|
|
74
|
+
return this.events.filter((event) => nameOf(event) === eventClass.eventName)
|
|
75
|
+
},
|
|
76
|
+
/** Sets the answer of `pendingRetries`. */
|
|
77
|
+
setPendingRetries(count) {
|
|
78
|
+
pending = count
|
|
79
|
+
},
|
|
80
|
+
/** Adds dead letters `deadLetters` hands out. */
|
|
81
|
+
queueDeadLetters(...items) {
|
|
82
|
+
letters.push(...items)
|
|
83
|
+
},
|
|
84
|
+
/** The ids passed to `requeue`, in order. */
|
|
85
|
+
get requeued() {
|
|
86
|
+
return [...requeued]
|
|
87
|
+
},
|
|
88
|
+
/** Makes the next call of `operation` reject with `error`, once. */
|
|
89
|
+
failNext(operation, error) {
|
|
90
|
+
failures.set(operation, error)
|
|
91
|
+
},
|
|
92
|
+
clear() {
|
|
93
|
+
for (const list of [written, letters, requeued]) list.length = 0
|
|
94
|
+
pending = 0
|
|
95
|
+
failures.clear()
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { recordingEventBus }
|
package/event-bus.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** An event as the bus port takes it: a class instance with a stable id; its name is the static `eventName` of its class. */
|
|
2
|
+
export interface RecordedEvent {
|
|
3
|
+
readonly eventId: string
|
|
4
|
+
}
|
|
5
|
+
/** The static side of an event class. */
|
|
6
|
+
export interface RecordedEventClass {
|
|
7
|
+
readonly eventName: string
|
|
8
|
+
}
|
|
9
|
+
/** One recorded publication. */
|
|
10
|
+
export interface EventWrite<E extends RecordedEvent = RecordedEvent> {
|
|
11
|
+
readonly event: E
|
|
12
|
+
readonly tx: unknown
|
|
13
|
+
/** Whether `tx` was the view a `fakeTransaction` body received. */
|
|
14
|
+
readonly inTransaction: boolean
|
|
15
|
+
}
|
|
16
|
+
/** A dead letter as the bus port returns it. */
|
|
17
|
+
export interface RecordedDeadLetter {
|
|
18
|
+
readonly id: string
|
|
19
|
+
readonly eventName: string
|
|
20
|
+
readonly eventId: string
|
|
21
|
+
readonly reason: string
|
|
22
|
+
readonly attempts: number
|
|
23
|
+
}
|
|
24
|
+
/** The bus operations `failNext` can make reject. */
|
|
25
|
+
export type EventBusOperation = "publish" | "pendingRetries" | "deadLetters" | "requeue"
|
|
26
|
+
|
|
27
|
+
/** An `EventBus` double that records publications and scripts the read side. */
|
|
28
|
+
export interface RecordingEventBus<E extends RecordedEvent = RecordedEvent> {
|
|
29
|
+
publish(event: E, tx: unknown): Promise<void>
|
|
30
|
+
pendingRetries(eventClass: RecordedEventClass): Promise<number>
|
|
31
|
+
deadLetters(eventClass: RecordedEventClass): Promise<ReadonlyArray<RecordedDeadLetter>>
|
|
32
|
+
requeue(id: string): Promise<void>
|
|
33
|
+
/** What the outbox would hold: the first publication of each (event name, event id). */
|
|
34
|
+
readonly events: ReadonlyArray<E>
|
|
35
|
+
/** Every publication with its manager, duplicates included. */
|
|
36
|
+
readonly entries: ReadonlyArray<EventWrite<E>>
|
|
37
|
+
/** Every event published, duplicates included. */
|
|
38
|
+
readonly writes: ReadonlyArray<E>
|
|
39
|
+
/** True when something was published and every publication went through a `fakeTransaction` manager. */
|
|
40
|
+
readonly allInTransaction: boolean
|
|
41
|
+
eventsOf(eventClass: RecordedEventClass): ReadonlyArray<E>
|
|
42
|
+
/** Sets the answer of `pendingRetries`. */
|
|
43
|
+
setPendingRetries(count: number): void
|
|
44
|
+
/** Adds dead letters `deadLetters` hands out. */
|
|
45
|
+
queueDeadLetters(...items: ReadonlyArray<RecordedDeadLetter>): void
|
|
46
|
+
/** The ids passed to `requeue`. */
|
|
47
|
+
readonly requeued: ReadonlyArray<string>
|
|
48
|
+
/** Makes the next call of `operation` reject with `error`, once. */
|
|
49
|
+
failNext(operation: EventBusOperation, error: Error): void
|
|
50
|
+
clear(): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The event bus double. */
|
|
54
|
+
export declare function recordingEventBus<E extends RecordedEvent = RecordedEvent>(): RecordingEventBus<E>
|
package/inbox.cjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `fakeInbox()` -- the twin of `recordingOutbox()` for the delivery side: an `Inbox` double with the behaviour of the real
|
|
5
|
+
* claim, so a spec asserts what a consumer or a signed webhook did with a delivery instead of stubbing `claim` by hand.
|
|
6
|
+
*
|
|
7
|
+
* `claim(source, eventId)` answers `true` for the first call of a (source, eventId) pair and `false` for every later one, like the
|
|
8
|
+
* real unique key; `release(source, eventId)` gives the claim back, so the next claim of the pair answers `true` again.
|
|
9
|
+
* `claims` is every claim in order, `claimed` the pairs that are held now, `released` every release, `seen(source, eventId)` marks a
|
|
10
|
+
* pair as already delivered (a redelivery), and `failNext(operation, error)` makes the next call of that operation reject once, so
|
|
11
|
+
* a spec drives the failure path.
|
|
12
|
+
*/
|
|
13
|
+
function fakeInbox() {
|
|
14
|
+
const held = new Set()
|
|
15
|
+
const claims = []
|
|
16
|
+
const released = []
|
|
17
|
+
const failures = new Map()
|
|
18
|
+
const keyOf = (source, eventId) => `${source}\u0000${eventId}`
|
|
19
|
+
const failIfScripted = (operation) => {
|
|
20
|
+
if (!failures.has(operation)) return
|
|
21
|
+
const error = failures.get(operation)
|
|
22
|
+
failures.delete(operation)
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async claim(source, eventId) {
|
|
27
|
+
failIfScripted("claim")
|
|
28
|
+
claims.push({ source, eventId })
|
|
29
|
+
const key = keyOf(source, eventId)
|
|
30
|
+
if (held.has(key)) return false
|
|
31
|
+
held.add(key)
|
|
32
|
+
return true
|
|
33
|
+
},
|
|
34
|
+
async release(source, eventId) {
|
|
35
|
+
failIfScripted("release")
|
|
36
|
+
released.push({ source, eventId })
|
|
37
|
+
held.delete(keyOf(source, eventId))
|
|
38
|
+
},
|
|
39
|
+
/** Marks a pair as already claimed, so the next `claim` of it answers `false` (a redelivery). */
|
|
40
|
+
seen(source, eventId) {
|
|
41
|
+
held.add(keyOf(source, eventId))
|
|
42
|
+
},
|
|
43
|
+
/** Makes the next call of `operation` reject with `error`, once. */
|
|
44
|
+
failNext(operation, error) {
|
|
45
|
+
failures.set(operation, error)
|
|
46
|
+
},
|
|
47
|
+
/** Every `claim`, in order, duplicates included. */
|
|
48
|
+
get claims() {
|
|
49
|
+
return [...claims]
|
|
50
|
+
},
|
|
51
|
+
/** The pairs held now: claimed and not released. */
|
|
52
|
+
get claimed() {
|
|
53
|
+
return [...held].map((key) => {
|
|
54
|
+
const [source, eventId] = key.split("\u0000")
|
|
55
|
+
return { source, eventId }
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
/** Every `release`, in order. */
|
|
59
|
+
get released() {
|
|
60
|
+
return [...released]
|
|
61
|
+
},
|
|
62
|
+
clear() {
|
|
63
|
+
held.clear()
|
|
64
|
+
claims.length = 0
|
|
65
|
+
released.length = 0
|
|
66
|
+
failures.clear()
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { fakeInbox }
|
package/inbox.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One claim or release of a delivery. */
|
|
2
|
+
export interface InboxEntry {
|
|
3
|
+
readonly source: string
|
|
4
|
+
readonly eventId: string
|
|
5
|
+
}
|
|
6
|
+
/** The inbox operations `failNext` can make reject. */
|
|
7
|
+
export type InboxOperation = "claim" | "release"
|
|
8
|
+
|
|
9
|
+
/** An `Inbox` double with the behaviour of the real claim. */
|
|
10
|
+
export interface FakeInbox {
|
|
11
|
+
/** True for the first claim of a (source, eventId) pair, false for every later one until it is released. */
|
|
12
|
+
claim(source: string, eventId: string): Promise<boolean>
|
|
13
|
+
/** Gives the claim back, so the next claim of the pair answers true. */
|
|
14
|
+
release(source: string, eventId: string): Promise<void>
|
|
15
|
+
/** Marks a pair as already claimed (a redelivery). */
|
|
16
|
+
seen(source: string, eventId: string): void
|
|
17
|
+
/** Makes the next call of `operation` reject with `error`, once. */
|
|
18
|
+
failNext(operation: InboxOperation, error: Error): void
|
|
19
|
+
/** Every claim, in order, duplicates included. */
|
|
20
|
+
readonly claims: ReadonlyArray<InboxEntry>
|
|
21
|
+
/** The pairs held now: claimed and not released. */
|
|
22
|
+
readonly claimed: ReadonlyArray<InboxEntry>
|
|
23
|
+
/** Every release, in order. */
|
|
24
|
+
readonly released: ReadonlyArray<InboxEntry>
|
|
25
|
+
clear(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The inbox double. */
|
|
29
|
+
export declare function fakeInbox(): FakeInbox
|
package/index.cjs
CHANGED
|
@@ -5,24 +5,36 @@ const { mockEntityManager, fakeTransaction } = require("./entity-manager.cjs")
|
|
|
5
5
|
const { FakeClock } = require("./clock.cjs")
|
|
6
6
|
const { fakeCache } = require("./cache.cjs")
|
|
7
7
|
const { fakeLock } = require("./lock.cjs")
|
|
8
|
-
const {
|
|
8
|
+
const { recordingEventBus } = require("./event-bus.cjs")
|
|
9
|
+
const { recordingQueueOutbox } = require("./queue.cjs")
|
|
10
|
+
const { fakeInbox } = require("./inbox.cjs")
|
|
9
11
|
const { builder } = require("./builders.cjs")
|
|
10
12
|
const { fakeIds, FakeIds } = require("./ids.cjs")
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* Coverage is measured
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
15
|
+
* Coverage is measured where logic runs: every file `<name>.<role>.ts` of a LOGIC role (service, policy, projection, guard, mapper, client, ...)
|
|
16
|
+
* inside the roots the slot manifest marks `coverage: required` (the modules of the back end: domain, integrations, platform, projections).
|
|
17
|
+
* The features (handlers, resolvers, controllers, consumers, processors, steps, sagas, cli commands) are thin by rule (BE_FEATURE_THIN) and are
|
|
18
|
+
* 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
|
|
19
|
+
* jest.config.js as `starciJestConfig({ coverage: { roots, roles, excludes } })`, and the same derivation renders Sonar's
|
|
20
|
+
* `sonar.coverage.exclusions` and the Codecov paths and components, so the three can never disagree. Every measured file must reach 100 on every metric.
|
|
21
|
+
* The unit run also writes `coverage/lcov.info`: Sonar and Codecov import it.
|
|
18
22
|
*/
|
|
19
|
-
const
|
|
20
|
-
const COVERAGE_EXCLUDES = ["src/tests/**", "**/dist/**", "**/coverage/**"]
|
|
23
|
+
const COVERAGE_EXCLUDES = ["**/dist/**", "**/coverage/**"]
|
|
21
24
|
/** The four metrics, each held at 100 per file. */
|
|
22
25
|
const COVERAGE_THRESHOLD = Object.freeze({ lines: 100, branches: 100, functions: 100, statements: 100 })
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
/** The glob one measured root stands for: every file of a logic role below it. */
|
|
28
|
+
function rootGlob(root, roles) {
|
|
29
|
+
return `${root}**/*.${roles.length === 1 ? roles[0] : `{${roles.join(",")}}`}.ts`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* jest's `collectCoverageFrom` of a coverage scope `{ roots, roles, excludes }`: the glob of every root, then the none directories inside a
|
|
34
|
+
* root (`excludes`, each a glob that ends in `**`) and the generated folders as negations.
|
|
35
|
+
*/
|
|
36
|
+
function collectCoverageFrom(coverage) {
|
|
37
|
+
return [...coverage.roots.map((root) => rootGlob(root, coverage.roles)), ...[...coverage.excludes, ...COVERAGE_EXCLUDES].map((glob) => `!${glob}`)]
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
/** Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. */
|
|
@@ -78,16 +90,16 @@ const underTests = (folder) => String.raw`[\\/]src[\\/]tests[\\/]` + folder + St
|
|
|
78
90
|
/**
|
|
79
91
|
* The whole jest config of a Nest repository: projects `unit` (colocated `<name>.spec.ts`), `integration`
|
|
80
92
|
* (`src/tests/integration/<capability>/<name>.integration-spec.ts`), `e2e` (`src/tests/e2e/<area>/<name>.e2e-spec.ts`) and
|
|
81
|
-
* `contract` (`src/tests/contract/<provider>/<name>.contract-spec.ts`). It takes
|
|
82
|
-
* (R05), rendered by `
|
|
93
|
+
* `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
|
|
94
|
+
* (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.
|
|
83
95
|
*
|
|
84
96
|
* The managed scripts select one project each (`test` = unit, `test:integration`, `test:e2e`, `test:contract`); the
|
|
85
97
|
* contract project is never part of `test` or `test:e2e`, and a contract spec skips itself without sandbox config. The
|
|
86
98
|
* test tree compiles against `src/tests/tsconfig.json`, the nearest config of every file under `src/tests/`. The
|
|
87
99
|
* integration, e2e and contract projects share the world's `global-setup.ts`/`global-teardown.ts` (`src/tests/world/`) and
|
|
88
|
-
* run their spec files
|
|
89
|
-
*
|
|
90
|
-
* Coverage is collected from
|
|
100
|
+
* run their spec files up to `min(--maxWorkers, slots)` at once, each in a worker process of its own bound to one data slot
|
|
101
|
+
* of the test world (`world-runner.cjs`; a slot's data is reset when a file boots, so no two files ever share a slot); the unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
|
|
102
|
+
* 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
|
|
91
103
|
* 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)
|
|
92
104
|
* as thousands of branches no spec can cover, while v8 measures the real source.
|
|
93
105
|
*/
|
|
@@ -99,7 +111,68 @@ function hasTestWorld(root) {
|
|
|
99
111
|
return require("node:fs").existsSync(require("node:path").join(root, "src", "tests", "world", "global-setup.ts"))
|
|
100
112
|
}
|
|
101
113
|
|
|
102
|
-
|
|
114
|
+
/** Every alternative a `{a,b}` pattern spells, braces expanded left to right. */
|
|
115
|
+
function braceVariants(value) {
|
|
116
|
+
const match = /{([^{}]+)}/.exec(value)
|
|
117
|
+
return match ? match[1].split(",").flatMap((part) => braceVariants(`${value.slice(0, match.index)}${part}${value.slice(match.index + match[0].length)}`)) : [value]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The anchored RegExp of one brace-free glob over a posix path: `**` spans directories, `*` and `?` stay inside a segment. */
|
|
121
|
+
function globRegExp(glob) {
|
|
122
|
+
let source = ""
|
|
123
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
124
|
+
const c = glob[i]
|
|
125
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
126
|
+
i += 1
|
|
127
|
+
if (glob[i + 1] === "/") {
|
|
128
|
+
i += 1
|
|
129
|
+
source += "(?:.*/)?"
|
|
130
|
+
} else source += ".*"
|
|
131
|
+
} else if (c === "*") source += "[^/]*"
|
|
132
|
+
else if (c === "?") source += "[^/]"
|
|
133
|
+
else source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c
|
|
134
|
+
}
|
|
135
|
+
return new RegExp(`^${source}$`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* True when the repository at `root` holds a file the coverage glob matches: jest refuses a threshold key that matches no covered file,
|
|
140
|
+
* 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,
|
|
141
|
+
* once added, are measured and held at 100 like every other).
|
|
142
|
+
*/
|
|
143
|
+
function hasCoverageSubjects(root, glob) {
|
|
144
|
+
const fsx = require("node:fs")
|
|
145
|
+
const pathx = require("node:path")
|
|
146
|
+
const base = glob.slice(0, Math.max(0, glob.search(/[*?{]/))).replace(/[^/]*$/, "")
|
|
147
|
+
const patterns = braceVariants(glob).map(globRegExp)
|
|
148
|
+
const walk = (dir) => {
|
|
149
|
+
let entries
|
|
150
|
+
try {
|
|
151
|
+
entries = fsx.readdirSync(pathx.join(root, dir), { withFileTypes: true })
|
|
152
|
+
} catch {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
return entries.some((entry) => {
|
|
156
|
+
const rel = `${dir}${entry.name}`
|
|
157
|
+
if (entry.isDirectory()) return !["node_modules", "dist", "coverage"].includes(entry.name) && walk(`${rel}/`)
|
|
158
|
+
return patterns.some((pattern) => pattern.test(rel))
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
return walk(base)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The scope `{ roots, roles, excludes }` of an options object, or a refusal: the scope is rendered by `starci app sync`, never defaulted. */
|
|
165
|
+
function coverageOf(options) {
|
|
166
|
+
const coverage = options?.coverage
|
|
167
|
+
const list = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0)
|
|
168
|
+
if (!coverage || !list(coverage.roots) || !list(coverage.roles) || !coverage.roles.length || !list(coverage.excludes)) {
|
|
169
|
+
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\"")
|
|
170
|
+
}
|
|
171
|
+
return coverage
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function starciJestConfig(options) {
|
|
175
|
+
const coverage = coverageOf(options)
|
|
103
176
|
const shared = {
|
|
104
177
|
preset: "ts-jest",
|
|
105
178
|
testEnvironment: "node",
|
|
@@ -122,9 +195,9 @@ function starciJestConfig() {
|
|
|
122
195
|
return {
|
|
123
196
|
testTimeout: 120_000,
|
|
124
197
|
coverageProvider: "v8",
|
|
125
|
-
collectCoverageFrom: collectCoverageFrom(),
|
|
126
|
-
// A glob key is applied to every matching file on its own: each
|
|
127
|
-
coverageThreshold:
|
|
198
|
+
collectCoverageFrom: collectCoverageFrom(coverage),
|
|
199
|
+
// A glob key is applied to every matching file on its own: each measured file must reach 100, not the average.
|
|
200
|
+
coverageThreshold: Object.fromEntries(coverage.roots.map((root) => rootGlob(root, coverage.roles)).filter((glob) => hasCoverageSubjects(process.cwd(), glob)).map((glob) => [`./${glob}`, { ...COVERAGE_THRESHOLD }])),
|
|
128
201
|
coverageDirectory: "coverage",
|
|
129
202
|
// lcov is what Sonar imports (coverage/lcov.info); text-summary and text are what the developer reads.
|
|
130
203
|
coverageReporters: ["text-summary", "text", "lcov"],
|
|
@@ -158,13 +231,16 @@ module.exports = {
|
|
|
158
231
|
FakeClock,
|
|
159
232
|
fakeCache,
|
|
160
233
|
fakeLock,
|
|
161
|
-
|
|
234
|
+
recordingEventBus,
|
|
235
|
+
recordingQueueOutbox,
|
|
236
|
+
fakeInbox,
|
|
162
237
|
builder,
|
|
163
238
|
fakeIds,
|
|
164
239
|
FakeIds,
|
|
165
240
|
collectCoverageFrom,
|
|
241
|
+
rootGlob,
|
|
166
242
|
sonarExclusions,
|
|
167
|
-
|
|
243
|
+
hasCoverageSubjects,
|
|
168
244
|
COVERAGE_EXCLUDES,
|
|
169
245
|
COVERAGE_THRESHOLD,
|
|
170
246
|
UNIT_COMPILER_OPTIONS,
|
package/index.d.ts
CHANGED
|
@@ -4,18 +4,32 @@ export { mock, createMock, MockOf } from "./mock"
|
|
|
4
4
|
export { mockEntityManager, fakeTransaction, MockEntityManager, FakeTransaction, EntityClass } from "./entity-manager"
|
|
5
5
|
export { fakeCache, FakeCache } from "./cache"
|
|
6
6
|
export { fakeLock, FakeLock } from "./lock"
|
|
7
|
-
export {
|
|
7
|
+
export { recordingEventBus, RecordingEventBus } from "./event-bus"
|
|
8
|
+
export { recordingQueueOutbox, RecordingQueueOutbox } from "./queue"
|
|
9
|
+
export { fakeInbox, FakeInbox } from "./inbox"
|
|
8
10
|
export { builder } from "./builders"
|
|
9
11
|
export { FakeClock } from "./clock"
|
|
10
12
|
export { fakeIds, FakeIds } from "./ids"
|
|
11
13
|
export { RefusalReason } from "./matchers"
|
|
12
14
|
|
|
13
|
-
/** The
|
|
14
|
-
export
|
|
15
|
+
/** The coverage scope `starci app sync` renders from the slot manifest: the measured roots (a directory pattern per measured slot, a placeholder folder written as a star), the logic roles measured in them and the directories inside a root that are never measured (each ending in a double star). */
|
|
16
|
+
export interface StarciCoverageScope {
|
|
17
|
+
readonly roots: readonly string[]
|
|
18
|
+
readonly roles: readonly string[]
|
|
19
|
+
readonly excludes: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
export interface StarciJestOptions {
|
|
22
|
+
readonly coverage: StarciCoverageScope
|
|
23
|
+
}
|
|
24
|
+
/** The whole jest config: projects `unit`, `integration`, `e2e` and `contract`. It takes the coverage scope the managed repository config renders; without it it refuses. */
|
|
25
|
+
export declare function starciJestConfig(options: StarciJestOptions): Config
|
|
15
26
|
export declare const MODULE_NAME_MAPPER: Readonly<Record<string, string>>
|
|
16
|
-
export declare function collectCoverageFrom(): string[]
|
|
27
|
+
export declare function collectCoverageFrom(coverage: StarciCoverageScope): string[]
|
|
28
|
+
/** The glob one measured root stands for: every file of a logic role below it. */
|
|
29
|
+
export declare function rootGlob(root: string, roles: readonly string[]): string
|
|
30
|
+
/** True when `root` holds a file the coverage `glob` matches (a threshold key that matches nothing is refused by jest). */
|
|
31
|
+
export declare function hasCoverageSubjects(root: string, glob: string): boolean
|
|
17
32
|
export declare function sonarExclusions(): string
|
|
18
|
-
export declare const COVERAGE_SOURCES: string[]
|
|
19
33
|
export declare const COVERAGE_EXCLUDES: string[]
|
|
20
34
|
/** The compiler options the unit project overlays on the repository tsconfig so per-file 100 coverage of a decorated service is reachable. */
|
|
21
35
|
export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules: false; importHelpers: true }>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/jest-preset",
|
|
3
|
-
"version": "2.2.
|
|
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(),
|
|
3
|
+
"version": "2.2.4",
|
|
4
|
+
"description": "The StarCi jest config for Nest repositories (unit, integration, e2e and contract projects, ts-jest with diagnostics off, per-file 100 coverage of *.service.ts only) and the one shared unit-test double kit: typed mock<T>(), mockEntityManager(), fakeTransaction(), fakeCache(), fakeLock(), recordingEventBus(), recordingQueueOutbox(), fakeInbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"main": "index.cjs",
|
|
@@ -39,9 +39,17 @@
|
|
|
39
39
|
"types": "./lock.d.ts",
|
|
40
40
|
"default": "./lock.cjs"
|
|
41
41
|
},
|
|
42
|
-
"./
|
|
43
|
-
"types": "./
|
|
44
|
-
"default": "./
|
|
42
|
+
"./event-bus": {
|
|
43
|
+
"types": "./event-bus.d.ts",
|
|
44
|
+
"default": "./event-bus.cjs"
|
|
45
|
+
},
|
|
46
|
+
"./queue": {
|
|
47
|
+
"types": "./queue.d.ts",
|
|
48
|
+
"default": "./queue.cjs"
|
|
49
|
+
},
|
|
50
|
+
"./inbox": {
|
|
51
|
+
"types": "./inbox.d.ts",
|
|
52
|
+
"default": "./inbox.cjs"
|
|
45
53
|
},
|
|
46
54
|
"./builders": {
|
|
47
55
|
"types": "./builders.d.ts",
|
package/queue.cjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `recordingQueueOutbox()` -- a `QueueOutbox` double that records what a typed queue producer writes, so a spec asserts on `outbox.jobs`.
|
|
5
|
+
*
|
|
6
|
+
* `write(tx, queue, payload)` records the job with the manager it was written through. `jobs` is every job in order, `jobsOf(queue)`
|
|
7
|
+
* the jobs of one queue, and `entries` adds `inTransaction`: whether `tx` was the view a `fakeTransaction` body received, so a spec proves
|
|
8
|
+
* a job is enqueued in the transaction of the change that needs it. `failNext('write', error)` makes the next write reject once.
|
|
9
|
+
*/
|
|
10
|
+
const { isTransactionManager } = require("./entity-manager.cjs")
|
|
11
|
+
|
|
12
|
+
function recordingQueueOutbox() {
|
|
13
|
+
const written = []
|
|
14
|
+
const failures = new Map()
|
|
15
|
+
return {
|
|
16
|
+
async write(tx, queue, payload) {
|
|
17
|
+
if (failures.has("write")) {
|
|
18
|
+
const error = failures.get("write")
|
|
19
|
+
failures.delete("write")
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
written.push({ queue, payload, tx, inTransaction: isTransactionManager(tx) })
|
|
23
|
+
},
|
|
24
|
+
/** Every job written, in order: `{ queue, payload }`. */
|
|
25
|
+
get jobs() {
|
|
26
|
+
return written.map(({ queue, payload }) => ({ queue, payload }))
|
|
27
|
+
},
|
|
28
|
+
/** Every write with its manager and `inTransaction`. */
|
|
29
|
+
get entries() {
|
|
30
|
+
return [...written]
|
|
31
|
+
},
|
|
32
|
+
/** The jobs of one queue. */
|
|
33
|
+
jobsOf(queue) {
|
|
34
|
+
return this.jobs.filter((job) => job.queue === queue)
|
|
35
|
+
},
|
|
36
|
+
/** True when something was written and every write went through a transaction manager. */
|
|
37
|
+
get allInTransaction() {
|
|
38
|
+
return written.length > 0 && written.every((entry) => entry.inTransaction)
|
|
39
|
+
},
|
|
40
|
+
/** Makes the next write reject with `error`, once. */
|
|
41
|
+
failNext(operation, error) {
|
|
42
|
+
failures.set(operation, error)
|
|
43
|
+
},
|
|
44
|
+
clear() {
|
|
45
|
+
written.length = 0
|
|
46
|
+
failures.clear()
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { recordingQueueOutbox }
|
package/queue.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
|
|
10
|
+
/** A `QueueOutbox` double that records the jobs a typed producer writes. */
|
|
11
|
+
export interface RecordingQueueOutbox {
|
|
12
|
+
write(tx: unknown, queue: string, payload: object): Promise<void>
|
|
13
|
+
/** Every job written, in order. */
|
|
14
|
+
readonly jobs: ReadonlyArray<{ readonly queue: string; readonly payload: object }>
|
|
15
|
+
/** Every write with its manager. */
|
|
16
|
+
readonly entries: ReadonlyArray<QueueWrite>
|
|
17
|
+
jobsOf(queue: string): ReadonlyArray<{ readonly queue: string; readonly payload: object }>
|
|
18
|
+
/** True when something was written and every write went through a `fakeTransaction` manager. */
|
|
19
|
+
readonly allInTransaction: boolean
|
|
20
|
+
/** Makes the next write reject with `error`, once. */
|
|
21
|
+
failNext(operation: "write", error: Error): void
|
|
22
|
+
clear(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The queue outbox double. */
|
|
26
|
+
export declare function recordingQueueOutbox(): RecordingQueueOutbox
|
package/world-runner.cjs
CHANGED
|
@@ -1,29 +1,76 @@
|
|
|
1
1
|
"use strict"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The runner of the integration, e2e and contract projects: every spec file runs in a process of its own
|
|
4
|
+
* The runner of the integration, e2e and contract projects: every spec file runs in a process of its own, bound to one data
|
|
5
|
+
* slot of the test world.
|
|
5
6
|
*
|
|
6
|
-
* Why: jest runs a file in a fresh vm context, but a context still reaches the globals of the process that
|
|
7
|
-
* jest-environment-node exposes every global the host process had when the environment loaded. The test world's
|
|
8
|
-
* runs in jest's main process and loads application code there (the migrate step and its modules), so a framework
|
|
9
|
-
* its registry on a process global (`@nestjs/graphql` keeps `GqlTypeMetadataStorage` on `global`) leaks it into
|
|
10
|
-
* jest runs in that process; and a worker that runs several files carries whatever the first one left on its own
|
|
11
|
-
*
|
|
12
|
-
* framework's storage would chase one framework at a time; a process per file isolates every one of them.
|
|
7
|
+
* 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
|
|
8
|
+
* hosts it, and jest-environment-node exposes every global the host process had when the environment loaded. The test world's
|
|
9
|
+
* globalSetup runs in jest's main process and loads application code there (the migrate step and its modules), so a framework
|
|
10
|
+
* that keeps its registry on a process global (`@nestjs/graphql` keeps `GqlTypeMetadataStorage` on `global`) leaks it into
|
|
11
|
+
* every file jest runs in that process; and a worker that runs several files carries whatever the first one left on its own
|
|
12
|
+
* global. A process per file isolates every framework at once.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Why slots: the test world resets a slot's data (its databases, realm users, Redis DB, buckets and fakes) when a file boots,
|
|
15
|
+
* so two files must never share a slot at the same time. The globalSetup of `@starci/test-world` provisions N slots, each a
|
|
16
|
+
* complete, independent set of data namespaces, and publishes them in its state file (`STARCI_TEST_WORLD_STATE`, protocol
|
|
17
|
+
* {@link WORLD_STATE_VERSION}). The runner runs up to `min(--maxWorkers, N)` files at once and gives each a free slot: the
|
|
18
|
+
* file's process is forked with `STARCI_TEST_WORLD_SLOT=<k>`; the slot goes back to the pool when the file ends.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* How: the stock jest-runner, driven one file at a time per slot with a single-worker farm (`maxWorkers: 1` per file), which
|
|
21
|
+
* forks a fresh child, runs the file there and ends the child. jest-worker forks in the farm's constructor, which jest-runner
|
|
22
|
+
* calls before its first `await`, so the slot variable is set on `process.env` only for that synchronous span. Never in band,
|
|
23
|
+
* even when jest would choose it (`--runInBand`): the main process holds the globalSetup's state.
|
|
24
|
+
*
|
|
25
|
+
* The pair is exact: this runner reads protocol {@link WORLD_STATE_VERSION} only. Any other state file is the wrong
|
|
26
|
+
* `@starci/test-world` beside this preset, a typed failure naming both versions (re-pin both per canon-pins), never a fallback.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
const { createRequire } = require("node:module")
|
|
30
|
+
const fs = require("node:fs")
|
|
25
31
|
const path = require("node:path")
|
|
26
32
|
|
|
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
|
+
|
|
42
|
+
const PRESET = (() => {
|
|
43
|
+
const own = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
|
44
|
+
return `${own.name}@${own.version}`
|
|
45
|
+
})()
|
|
46
|
+
|
|
47
|
+
/** A typed failure of the runner: `code` names it, the message says what to do. */
|
|
48
|
+
function runnerError(code, detail) {
|
|
49
|
+
const error = new Error(`${code}: ${detail}`)
|
|
50
|
+
error.code = code
|
|
51
|
+
return error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The slot count the world published; a missing or foreign state file is a pair mismatch. */
|
|
55
|
+
function worldSlots(env = process.env) {
|
|
56
|
+
const file = env[STATE_FILE_ENV]
|
|
57
|
+
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)`
|
|
58
|
+
if (file === undefined || file === "") {
|
|
59
|
+
throw runnerError(WORLD_PAIR_MISMATCH, `${STATE_FILE_ENV} is not set: the world's globalSetup published no state file for ${PRESET}; ${fix}`)
|
|
60
|
+
}
|
|
61
|
+
let state
|
|
62
|
+
try {
|
|
63
|
+
state = JSON.parse(fs.readFileSync(file, "utf8"))
|
|
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 library = typeof state?.library === "string" ? state.library : "@starci/test-world before 1.1.0"
|
|
68
|
+
if (state?.version !== WORLD_STATE_VERSION || !Array.isArray(state.slots) || state.slots.length < 1) {
|
|
69
|
+
throw runnerError(WORLD_PAIR_MISMATCH, `${library} wrote state protocol ${String(state?.version)}, ${PRESET} needs ${WORLD_STATE_VERSION}; ${fix}`)
|
|
70
|
+
}
|
|
71
|
+
return state.slots.length
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
/**
|
|
28
75
|
* The stock jest-runner of the jest that loads this preset: resolved through jest's own dependency chain
|
|
29
76
|
* (jest -> @jest/core -> jest-runner) from the repository root, so the runner always matches the running jest.
|
|
@@ -50,20 +97,46 @@ class WorldRunner extends StockRunner {
|
|
|
50
97
|
}
|
|
51
98
|
}
|
|
52
99
|
|
|
53
|
-
/** Runs the files
|
|
100
|
+
/** Runs the files up to `min(--maxWorkers, slots)` at once, each in a fresh process bound to a free slot; never in band. */
|
|
54
101
|
async runTests(tests, watcher) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for (const stop of unsubscribe) stop()
|
|
102
|
+
const slots = worldSlots()
|
|
103
|
+
const width = Math.max(1, Math.min(this._globalConfig.maxWorkers ?? 1, slots))
|
|
104
|
+
const queue = [...tests]
|
|
105
|
+
// one lane per slot: a lane owns its slot for the whole run and takes the next file when its current one ends
|
|
106
|
+
const lane = async (slot) => {
|
|
107
|
+
for (let test = queue.shift(); test !== undefined; test = queue.shift()) {
|
|
108
|
+
if (watcher.isInterrupted()) return
|
|
109
|
+
await this.#runOne(test, watcher, slot)
|
|
64
110
|
}
|
|
65
111
|
}
|
|
112
|
+
await Promise.all(Array.from({ length: Math.min(width, tests.length) }, (_, index) => lane(index + 1)))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One file in a fresh single-worker farm whose child is forked with the slot in its environment. */
|
|
116
|
+
async #runOne(test, watcher, slot) {
|
|
117
|
+
const runner = new StockRunner({ ...this._globalConfig, maxWorkers: 1 }, this._context)
|
|
118
|
+
const unsubscribe = this.#listeners.map(({ eventName, listener }) => runner.on(eventName, listener))
|
|
119
|
+
const before = process.env[SLOT_ENV]
|
|
120
|
+
let running
|
|
121
|
+
process.env[SLOT_ENV] = String(slot)
|
|
122
|
+
try {
|
|
123
|
+
// the farm (and its child) is created synchronously inside this call, before jest-runner's first await
|
|
124
|
+
running = runner.runTests([test], watcher, { serial: false })
|
|
125
|
+
} finally {
|
|
126
|
+
if (before === undefined) delete process.env[SLOT_ENV]
|
|
127
|
+
else process.env[SLOT_ENV] = before
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await running
|
|
131
|
+
} finally {
|
|
132
|
+
for (const stop of unsubscribe) stop()
|
|
133
|
+
}
|
|
66
134
|
}
|
|
67
135
|
}
|
|
68
136
|
|
|
137
|
+
WorldRunner.WORLD_STATE_VERSION = WORLD_STATE_VERSION
|
|
138
|
+
WorldRunner.SLOT_ENV = SLOT_ENV
|
|
139
|
+
WorldRunner.WORLD_PAIR_MISMATCH = WORLD_PAIR_MISMATCH
|
|
140
|
+
WorldRunner.worldSlots = worldSlots
|
|
141
|
+
|
|
69
142
|
module.exports = WorldRunner
|
package/outbox.cjs
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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>
|