@nifrajs/testing 1.11.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.
- package/README.md +64 -0
- package/dist/certification.d.ts +181 -0
- package/dist/certification.d.ts.map +1 -0
- package/dist/certification.js +483 -0
- package/dist/certification.js.map +1 -0
- package/dist/failure-lab.d.ts +133 -0
- package/dist/failure-lab.d.ts.map +1 -0
- package/dist/failure-lab.js +207 -0
- package/dist/failure-lab.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
- package/src/certification.ts +669 -0
- package/src/failure-lab.ts +356 -0
- package/src/index.ts +39 -0
|
@@ -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,
|