@starci/jest-preset 2.2.3 → 2.2.5

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 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) StarCi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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`, `recordingOutbox`, `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)).
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: `hfs sync` renders it, `hfs check` compares it; do not edit)
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
- `starciJestConfig()` takes no options: a repository has no jest setting to tune, so the file above is the whole config.
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>.service.spec.ts` beside each `*.service.ts`; `clearMocks`; the Outcome matchers are installed through `setupFilesAfterEnv`; ignores
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 ONE FILE AT A TIME whatever
21
- `--maxWorkers` says, because every file shares the run's data (the test world resets it when a file boots). Only the unit project has a `setupFilesAfterEnv`. The managed scripts select one
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 services only, per-file 100.** `collectCoverageFrom` is `src/**/*.service.ts` minus `src/tests/**`, `dist` and
29
- `coverage`, and `coverageThreshold` is `{ "./src/**/*.service.ts": { lines: 100, branches: 100, functions: 100, statements: 100 } }`,
30
- a glob key that applies to each file on its own, so every service must reach 100 on every metric (an average never passes). The
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 services.** `hfs sync` renders `sonar.coverage.exclusions` (the complement of the services: SonarQube has no coverage inclusions) and the `codecov.yml`
38
- status paths from `COVERAGE_SOURCES` on the be side (`be/src/**/*.service.ts`), so jest, Sonar and Codecov measure the same files;
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
- Only services are unit-tested, through `Test.createTestingModule`, with the doubles of this package and nothing else:
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`, `./outbox` and `./builders` exist as well.
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 `recordingOutbox()`
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 outbox = recordingOutbox() // enqueue(manager, message)
118
- outbox.messages // what the store would hold: the first write of each (queue, eventId)
119
- outbox.writes // every message written, duplicates included
120
- outbox.entries // every write with its manager and `inTransaction`
121
- outbox.allInTransaction; outbox.messagesOf("queue"); outbox.clear()
122
-
123
- // the claim side: script what a worker's claim returns
124
- outbox.queueRecords(record) // the backlog claimDue hands out, in order, filtered by queue and cut at limit
125
- outbox.queueRecords(record, record) // a duplicate delivery
126
- await outbox.claimDue(params) // [] when the backlog is empty
127
- outbox.failNext("complete", new Error("db down")) // the next call of that operation rejects once
128
- outbox.claims; outbox.completed; outbox.retried; outbox.buried; outbox.backlog
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
@@ -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; `recordingOutbox` asks whether a manager is one of them. */
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. */
@@ -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 `recordingOutbox`). */
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 { recordingOutbox } = require("./outbox.cjs")
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 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
- * The unit run also writes `coverage/lcov.info`: Sonar imports it (`sonar.javascript.lcov.reportPaths`) with the same scope
17
- * (`sonar.coverage.exclusions` holds every other file, rendered by hfs sync) and its quality gate holds coverage at 100 on those files.
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 COVERAGE_SOURCES = ["src/**/*.service.ts"]
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
- function collectCoverageFrom() {
25
- return [...COVERAGE_SOURCES, ...COVERAGE_EXCLUDES.map((glob) => `!${glob}`)]
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 no options: the repository's `jest.config.js` is a managed file
82
- * (R05), rendered by `hfs sync` as `module.exports = require("@starci/jest-preset").starciJestConfig()`.
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 one at a time, each in a worker process of its own (`world-runner.cjs`; every file shares the
89
- * run's data, so the runner never runs two at once); the unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
90
- * Coverage is collected from every `*.service.ts` only, with a per-file threshold of 100 on lines, branches, functions and
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
- function starciJestConfig() {
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 service file must reach 100, not the average.
127
- coverageThreshold: { "./src/**/*.service.ts": { ...COVERAGE_THRESHOLD } },
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
- recordingOutbox,
234
+ recordingEventBus,
235
+ recordingQueueOutbox,
236
+ fakeInbox,
162
237
  builder,
163
238
  fakeIds,
164
239
  FakeIds,
165
240
  collectCoverageFrom,
241
+ rootGlob,
166
242
  sonarExclusions,
167
- COVERAGE_SOURCES,
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 { recordingOutbox, RecordingOutbox } from "./outbox"
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 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
+ /** 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,8 +1,8 @@
1
1
  {
2
2
  "name": "@starci/jest-preset",
3
- "version": "2.2.3",
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(), recordingOutbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
5
- "license": "UNLICENSED",
3
+ "version": "2.2.5",
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
+ "license": "MIT",
6
6
  "private": false,
7
7
  "main": "index.cjs",
8
8
  "types": "index.d.ts",
@@ -39,9 +39,17 @@
39
39
  "types": "./lock.d.ts",
40
40
  "default": "./lock.cjs"
41
41
  },
42
- "./outbox": {
43
- "types": "./outbox.d.ts",
44
- "default": "./outbox.cjs"
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",
@@ -49,6 +57,7 @@
49
57
  }
50
58
  },
51
59
  "files": [
60
+ "LICENSE",
52
61
  "*.cjs",
53
62
  "*.d.ts",
54
63
  "!*.spec.*",
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 hosts it, and
7
- * jest-environment-node exposes every global the host process had when the environment loaded. The test world's globalSetup
8
- * runs in jest's main process and loads application code there (the migrate step and its modules), so a framework that keeps
9
- * its registry on a process global (`@nestjs/graphql` keeps `GqlTypeMetadataStorage` on `global`) leaks it into every file
10
- * jest runs in that process; and a worker that runs several files carries whatever the first one left on its own global. In
11
- * one `npm run test:e2e` the second file then fails to boot with "Cannot determine a GraphQL output type". Resetting the
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
- * How: the stock jest-runner, driven one file at a time with a single-worker farm (`maxWorkers: 1` per file), which forks a
15
- * fresh child, runs the file there and ends the child. Never in band, even when jest would choose it (one worker, one test,
16
- * `--runInBand`): the main process holds the globalSetup's state.
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
- * ONE FILE AT A TIME, whatever `--maxWorkers` says: every file of a run shares the run's data (the test world resets the run's
19
- * databases, realm users, Redis DB and fakes when a file boots), so a second file running beside the first would wipe what the
20
- * first is using. The runner enforces it, so no flag or script can turn it off. Per-worker data namespaces are the known
21
- * limitation that lifts it (see CHANGELOG 2.2.0).
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 one after another, each in a fresh worker process; `--maxWorkers` and `serial` (in band) are never honoured. */
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 perFile = { ...this._globalConfig, maxWorkers: 1 }
56
- for (const test of tests) {
57
- if (watcher.isInterrupted()) return
58
- const runner = new StockRunner(perFile, this._context)
59
- const unsubscribe = this.#listeners.map(({ eventName, listener }) => runner.on(eventName, listener))
60
- try {
61
- await runner.runTests([test], watcher, { serial: false })
62
- } finally {
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>