@nifrajs/testing 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Deterministic durable-failure laboratory.
3
+ *
4
+ * The controller is an off-hot-path port: tests place named checkpoints around real adapters and a
5
+ * replayable schedule injects crashes, duplicate/reordered delivery, virtual delay, deadline expiry,
6
+ * lost provider replies, or checkpoint contention. Evidence contains tokens only - never payloads,
7
+ * provider results, exception messages, or stacks.
8
+ */
9
+
10
+ export type FailureKind =
11
+ | "crash"
12
+ | "duplicate-delivery"
13
+ | "reorder-events"
14
+ | "delay"
15
+ | "expire-budget"
16
+ | "lose-provider-reply"
17
+ | "contend-checkpoint"
18
+
19
+ interface FailureDirectiveBase {
20
+ readonly point: string
21
+ /** 1-based invocation of this kind at this point. Default 1. */
22
+ readonly occurrence?: number
23
+ }
24
+
25
+ export type FailureDirective =
26
+ | (FailureDirectiveBase & { readonly kind: "crash" })
27
+ | (FailureDirectiveBase & {
28
+ readonly kind: "duplicate-delivery"
29
+ /** Total copies of every delivery, including the original. Default 2, maximum 10. */
30
+ readonly copies?: number
31
+ })
32
+ | (FailureDirectiveBase & { readonly kind: "reorder-events" })
33
+ | (FailureDirectiveBase & { readonly kind: "delay"; readonly delayMs: number })
34
+ | (FailureDirectiveBase & { readonly kind: "expire-budget" })
35
+ | (FailureDirectiveBase & { readonly kind: "lose-provider-reply" })
36
+ | (FailureDirectiveBase & { readonly kind: "contend-checkpoint" })
37
+
38
+ type NormalizedFailureDirective =
39
+ | { readonly kind: "crash"; readonly point: string; readonly occurrence: number }
40
+ | {
41
+ readonly kind: "duplicate-delivery"
42
+ readonly point: string
43
+ readonly occurrence: number
44
+ readonly copies: number
45
+ }
46
+ | { readonly kind: "reorder-events"; readonly point: string; readonly occurrence: number }
47
+ | {
48
+ readonly kind: "delay"
49
+ readonly point: string
50
+ readonly occurrence: number
51
+ readonly delayMs: number
52
+ }
53
+ | { readonly kind: "expire-budget"; readonly point: string; readonly occurrence: number }
54
+ | {
55
+ readonly kind: "lose-provider-reply"
56
+ readonly point: string
57
+ readonly occurrence: number
58
+ }
59
+ | {
60
+ readonly kind: "contend-checkpoint"
61
+ readonly point: string
62
+ readonly occurrence: number
63
+ }
64
+
65
+ export interface FailureEvidence {
66
+ readonly sequence: number
67
+ readonly kind: FailureKind
68
+ readonly point: string
69
+ readonly occurrence: number
70
+ readonly virtualTimeMs: number
71
+ }
72
+
73
+ export interface FailureReplay {
74
+ readonly seed: number
75
+ readonly schedule: readonly NormalizedFailureDirective[]
76
+ }
77
+
78
+ export interface FailureLabOptions {
79
+ /** Replayable unsigned seed. Default is stable. */
80
+ readonly seed?: number
81
+ readonly schedule: readonly FailureDirective[]
82
+ /** Virtual epoch/monotonic start. No real sleep is ever performed. Default 0. */
83
+ readonly startTimeMs?: number
84
+ }
85
+
86
+ export interface FailureLab {
87
+ /** Apply virtual delay and/or crash directives at a named durability seam. */
88
+ checkpoint(point: string): void
89
+ /** Apply duplicate/reorder directives to a delivery batch without inspecting its values. */
90
+ deliveries<T>(point: string, values: readonly T[]): readonly T[]
91
+ /** Run the provider operation, then optionally lose only its reply. */
92
+ provider<T>(point: string, operation: () => T | Promise<T>): Promise<T>
93
+ /** Return zero when this budget seam is scheduled to expire; otherwise clamp the supplied value. */
94
+ remaining(point: string, remainingMs: number): number
95
+ /** True only for a scheduled checkpoint conflict occurrence. */
96
+ checkpointContended(point: string): boolean
97
+ /** Current virtual time. */
98
+ now(): number
99
+ /** Immutable, token-only injection evidence. */
100
+ evidence(): readonly FailureEvidence[]
101
+ /** Exact normalized replay inputs. */
102
+ replay(): FailureReplay
103
+ }
104
+
105
+ export class FailureInjectedError extends Error {
106
+ override readonly name = "FailureInjectedError"
107
+
108
+ constructor(
109
+ readonly kind: "crash" | "lose-provider-reply",
110
+ readonly point: string,
111
+ readonly occurrence: number,
112
+ ) {
113
+ super(`failure laboratory injected ${kind} at ${point}#${occurrence}`)
114
+ }
115
+ }
116
+
117
+ export interface FailureScenario<Output> {
118
+ readonly name: string
119
+ execute(lab: FailureLab): Output | Promise<Output>
120
+ /** The scenario passes only when this post-failure invariant returns true. */
121
+ verify(context: {
122
+ readonly lab: FailureLab
123
+ readonly result?: Output
124
+ readonly error?: unknown
125
+ }): boolean | Promise<boolean>
126
+ }
127
+
128
+ export interface FailureScenarioReport {
129
+ readonly name: string
130
+ readonly ok: boolean
131
+ readonly replay: FailureReplay
132
+ readonly evidence: readonly FailureEvidence[]
133
+ /** Sanitized failure identity. Messages and stacks are deliberately excluded. */
134
+ readonly error?: {
135
+ readonly name: string
136
+ readonly kind?: FailureInjectedError["kind"]
137
+ }
138
+ }
139
+
140
+ const DEFAULT_SEED = 0x46_41_49_4c
141
+ const MAX_DIRECTIVES = 128
142
+ const MAX_OCCURRENCE = 10_000
143
+ const MAX_COPIES = 10
144
+ const MAX_DELAY_MS = 24 * 60 * 60_000
145
+ const POINT = /^[a-z][a-z0-9._:-]{0,127}$/
146
+ const SCENARIO = /^[a-z][a-z0-9._-]{0,127}$/
147
+
148
+ const integerIn = (value: number, min: number, max: number, label: string): number => {
149
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
150
+ throw new RangeError(`failure laboratory: ${label} must be an integer from ${min} to ${max}`)
151
+ }
152
+ return value
153
+ }
154
+
155
+ const pointOf = (value: string): string => {
156
+ if (!POINT.test(value)) {
157
+ throw new TypeError(`failure laboratory: invalid failure point ${JSON.stringify(value)}`)
158
+ }
159
+ return value
160
+ }
161
+
162
+ const normalizeSeed = (seed: number | undefined): number =>
163
+ Number.isFinite(seed) ? Math.trunc(seed as number) >>> 0 : DEFAULT_SEED
164
+
165
+ const hash = (value: string): number => {
166
+ let result = 2_166_136_261
167
+ for (let index = 0; index < value.length; index += 1) {
168
+ result ^= value.charCodeAt(index)
169
+ result = Math.imul(result, 16_777_619)
170
+ }
171
+ return result >>> 0
172
+ }
173
+
174
+ function normalizeSchedule(
175
+ schedule: readonly FailureDirective[],
176
+ ): readonly NormalizedFailureDirective[] {
177
+ if (!Array.isArray(schedule) || schedule.length > MAX_DIRECTIVES) {
178
+ throw new RangeError(`failure laboratory: schedule cannot exceed ${MAX_DIRECTIVES} directives`)
179
+ }
180
+ const seen = new Set<string>()
181
+ return Object.freeze(
182
+ schedule.map((directive) => {
183
+ const point = pointOf(directive.point)
184
+ const occurrence = integerIn(directive.occurrence ?? 1, 1, MAX_OCCURRENCE, "occurrence")
185
+ const identity = `${directive.kind}\n${point}\n${occurrence}`
186
+ if (seen.has(identity)) {
187
+ throw new Error(
188
+ `failure laboratory: duplicate directive ${directive.kind} at ${point}#${occurrence}`,
189
+ )
190
+ }
191
+ seen.add(identity)
192
+ if (directive.kind === "duplicate-delivery") {
193
+ return Object.freeze({
194
+ kind: directive.kind,
195
+ point,
196
+ occurrence,
197
+ copies: integerIn(directive.copies ?? 2, 2, MAX_COPIES, "copies"),
198
+ })
199
+ }
200
+ if (directive.kind === "delay") {
201
+ return Object.freeze({
202
+ kind: directive.kind,
203
+ point,
204
+ occurrence,
205
+ delayMs: integerIn(directive.delayMs, 1, MAX_DELAY_MS, "delayMs"),
206
+ })
207
+ }
208
+ return Object.freeze({ kind: directive.kind, point, occurrence })
209
+ }),
210
+ )
211
+ }
212
+
213
+ /** Build one isolated deterministic controller. Construct a fresh lab for every replay. */
214
+ export function createFailureLab(options: FailureLabOptions): FailureLab {
215
+ const seed = normalizeSeed(options.seed)
216
+ const schedule = normalizeSchedule(options.schedule)
217
+ let virtualTimeMs = options.startTimeMs ?? 0
218
+ if (!Number.isSafeInteger(virtualTimeMs) || virtualTimeMs < 0) {
219
+ throw new RangeError("failure laboratory: startTimeMs must be a non-negative safe integer")
220
+ }
221
+ const counts = new Map<string, number>()
222
+ const emitted: FailureEvidence[] = []
223
+ let sequence = 0
224
+
225
+ const directives = (
226
+ point: string,
227
+ kinds: readonly FailureKind[],
228
+ ): readonly NormalizedFailureDirective[] => {
229
+ pointOf(point)
230
+ const occurrences = new Map<FailureKind, number>()
231
+ for (const kind of kinds) {
232
+ const key = `${kind}\n${point}`
233
+ const occurrence = (counts.get(key) ?? 0) + 1
234
+ counts.set(key, occurrence)
235
+ occurrences.set(kind, occurrence)
236
+ }
237
+ return schedule.filter(
238
+ (directive) =>
239
+ directive.point === point && occurrences.get(directive.kind) === directive.occurrence,
240
+ )
241
+ }
242
+
243
+ const emit = (directive: NormalizedFailureDirective): void => {
244
+ emitted.push(
245
+ Object.freeze({
246
+ sequence: ++sequence,
247
+ kind: directive.kind,
248
+ point: directive.point,
249
+ occurrence: directive.occurrence,
250
+ virtualTimeMs,
251
+ }),
252
+ )
253
+ }
254
+
255
+ const lab: FailureLab = {
256
+ checkpoint(point) {
257
+ for (const directive of directives(point, ["delay", "crash"])) {
258
+ if (directive.kind === "delay") virtualTimeMs += directive.delayMs
259
+ emit(directive)
260
+ if (directive.kind === "crash") {
261
+ throw new FailureInjectedError("crash", point, directive.occurrence)
262
+ }
263
+ }
264
+ },
265
+
266
+ deliveries<T>(point: string, values: readonly T[]): readonly T[] {
267
+ let output = [...values]
268
+ for (const directive of directives(point, ["duplicate-delivery", "reorder-events"])) {
269
+ if (directive.kind === "duplicate-delivery") {
270
+ output = output.flatMap((value) => Array.from({ length: directive.copies }, () => value))
271
+ } else if (directive.kind === "reorder-events" && output.length > 1) {
272
+ const offset =
273
+ 1 + (hash(`${seed}:${point}:${directive.occurrence}`) % (output.length - 1))
274
+ output = [...output.slice(offset), ...output.slice(0, offset)]
275
+ }
276
+ emit(directive)
277
+ }
278
+ return Object.freeze(output)
279
+ },
280
+
281
+ async provider<T>(point: string, operation: () => T | Promise<T>): Promise<T> {
282
+ pointOf(point)
283
+ const result = await operation()
284
+ for (const directive of directives(point, ["lose-provider-reply"])) {
285
+ emit(directive)
286
+ throw new FailureInjectedError("lose-provider-reply", point, directive.occurrence)
287
+ }
288
+ return result
289
+ },
290
+
291
+ remaining(point, remainingMs) {
292
+ if (!Number.isFinite(remainingMs)) {
293
+ throw new TypeError("failure laboratory: remainingMs must be finite")
294
+ }
295
+ const remaining = Math.max(0, remainingMs)
296
+ const injected = directives(point, ["expire-budget"])
297
+ for (const directive of injected) emit(directive)
298
+ return injected.length > 0 ? 0 : remaining
299
+ },
300
+
301
+ checkpointContended(point) {
302
+ const injected = directives(point, ["contend-checkpoint"])
303
+ for (const directive of injected) emit(directive)
304
+ return injected.length > 0
305
+ },
306
+
307
+ now: () => virtualTimeMs,
308
+ evidence: () => Object.freeze([...emitted]),
309
+ replay: () => Object.freeze({ seed, schedule }),
310
+ }
311
+ return Object.freeze(lab)
312
+ }
313
+
314
+ /** Run one scenario and evaluate its post-failure invariant without leaking its result or error text. */
315
+ export async function runFailureScenario<Output>(
316
+ scenario: FailureScenario<Output>,
317
+ options: FailureLabOptions,
318
+ ): Promise<FailureScenarioReport> {
319
+ if (!SCENARIO.test(scenario.name)) {
320
+ throw new TypeError(
321
+ `failure laboratory: invalid scenario name ${JSON.stringify(scenario.name)}`,
322
+ )
323
+ }
324
+ const lab = createFailureLab(options)
325
+ let result: Output | undefined
326
+ let error: unknown
327
+ try {
328
+ result = await scenario.execute(lab)
329
+ } catch (caught) {
330
+ error = caught
331
+ }
332
+ let ok = false
333
+ try {
334
+ ok =
335
+ (await scenario.verify({
336
+ lab,
337
+ ...(error === undefined ? { result: result as Output } : { error }),
338
+ })) === true
339
+ } catch {
340
+ ok = false
341
+ }
342
+ const errorSummary =
343
+ error === undefined
344
+ ? undefined
345
+ : Object.freeze({
346
+ name: error instanceof Error ? error.name : "NonErrorThrown",
347
+ ...(error instanceof FailureInjectedError ? { kind: error.kind } : {}),
348
+ })
349
+ return Object.freeze({
350
+ name: scenario.name,
351
+ ok,
352
+ replay: lab.replay(),
353
+ evidence: lab.evidence(),
354
+ ...(errorSummary === undefined ? {} : { error: errorSummary }),
355
+ })
356
+ }
package/src/index.ts CHANGED
@@ -25,7 +25,46 @@ export {
25
25
  type ContractWitness,
26
26
  runAdversarialContract,
27
27
  } from "./adversarial.ts"
28
+ export {
29
+ AdapterCertificationError,
30
+ type AdapterCertificationProfile,
31
+ type AdapterCertificationReport,
32
+ assertAdapterCertification,
33
+ type CertifiableCacheEntry,
34
+ type CertifiableCacheStore,
35
+ type CertifiableDomainEvent,
36
+ type CertifiableEventDeliveryAdapter,
37
+ type CertifiableEventRecord,
38
+ type CertifiableJobStore,
39
+ type CertifiableRuntimeAdapter,
40
+ type CertifiableRuntimeServer,
41
+ type CertifiableStorageAdapter,
42
+ type CertificationCapabilityEvidence,
43
+ type CertificationCheck,
44
+ type CertificationCheckEvidence,
45
+ cacheStoreCertificationProfile,
46
+ certifyAdapter,
47
+ defineCertificationProfile,
48
+ eventDeliveryCertificationProfile,
49
+ jobStoreCertificationProfile,
50
+ runtimeAdapterCertificationProfile,
51
+ storageAdapterCertificationProfile,
52
+ verifyAdapterCertification,
53
+ } from "./certification.ts"
28
54
  export { type CookieJar, cookieJar } from "./cookies.ts"
55
+ export {
56
+ createFailureLab,
57
+ type FailureDirective,
58
+ type FailureEvidence,
59
+ FailureInjectedError,
60
+ type FailureKind,
61
+ type FailureLab,
62
+ type FailureLabOptions,
63
+ type FailureReplay,
64
+ type FailureScenario,
65
+ type FailureScenarioReport,
66
+ runFailureScenario,
67
+ } from "./failure-lab.ts"
29
68
  export {
30
69
  assertIncidentReplays,
31
70
  type CapturedRequest,