@nifrajs/testing 1.7.0 → 1.10.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,300 @@
1
+ /**
2
+ * Turn a failed request into a committed regression test — the one thing a generic error tracker
3
+ * (Sentry, PostHog) can't do, because it needs the framework's contract + in-process replay. Capture
4
+ * the request + observed response, replay it against the CURRENT app, and assert the response contract
5
+ * (status, optionally shape) still reproduces. This does NOT replace error tracking or store incidents
6
+ * — it converts one into a permanent test.
7
+ *
8
+ * Two surfaces, deliberately separate on the PII axis:
9
+ * - `replayIncident` / `assertIncidentReplays` run in memory against the REAL captured inputs — nothing
10
+ * is written, so reproduction is exact and there is no leak surface.
11
+ * - `generateRegressionTest` emits a file you commit, so it redacts request values BY DEFAULT and
12
+ * bannered a sanitize step: a committed fixture must never carry raw PII/secrets.
13
+ */
14
+
15
+ import type { AppLike } from "./session.ts"
16
+
17
+ export interface CapturedRequestInput {
18
+ readonly method?: string
19
+ readonly path: string
20
+ readonly query?: Record<string, string>
21
+ readonly headers?: Record<string, string>
22
+ readonly body?: unknown
23
+ }
24
+
25
+ export interface CapturedRequest {
26
+ readonly method: string
27
+ readonly path: string
28
+ readonly query?: Record<string, string>
29
+ readonly headers?: Record<string, string>
30
+ readonly body?: unknown
31
+ }
32
+
33
+ export interface IncidentCapsule {
34
+ readonly id: string
35
+ readonly capturedAt: string
36
+ readonly request: CapturedRequest
37
+ readonly response: { readonly status: number; readonly bodyShape?: unknown }
38
+ readonly note?: string
39
+ }
40
+
41
+ export interface CaptureIncidentOptions {
42
+ readonly id?: string
43
+ readonly capturedAt?: string
44
+ /** Header names to keep on the capsule (lowercased match). Default: only `content-type`. */
45
+ readonly headerAllowList?: readonly string[]
46
+ readonly note?: string
47
+ }
48
+
49
+ export interface IncidentReplayResult {
50
+ readonly reproduced: boolean
51
+ readonly status: number
52
+ readonly expectedStatus: number
53
+ readonly statusMatches: boolean
54
+ /** null when shape was not checked. */
55
+ readonly shapeMatches: boolean | null
56
+ }
57
+
58
+ const DEFAULT_ORIGIN = "http://nifra.internal"
59
+ const DEFAULT_HEADER_ALLOW = ["content-type"]
60
+
61
+ function newId(): string {
62
+ return `inc_${globalThis.crypto.randomUUID()}`
63
+ }
64
+
65
+ /** A stable structural fingerprint: keys + value *types*, not values. Used for the optional shape check. */
66
+ export function shapeOf(value: unknown): unknown {
67
+ if (value === null) return "null"
68
+ if (Array.isArray(value)) return value.length === 0 ? "[]" : [shapeOf(value[0])]
69
+ if (typeof value === "object") {
70
+ const out: Record<string, unknown> = {}
71
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
72
+ out[key] = shapeOf((value as Record<string, unknown>)[key])
73
+ }
74
+ return out
75
+ }
76
+ return typeof value
77
+ }
78
+
79
+ function shapesEqual(a: unknown, b: unknown): boolean {
80
+ return JSON.stringify(a) === JSON.stringify(b)
81
+ }
82
+
83
+ async function readBody(source: Request | Response): Promise<unknown> {
84
+ const contentType = source.headers.get("content-type") ?? ""
85
+ const clone = source.clone()
86
+ const text = await clone.text()
87
+ if (text === "") return undefined
88
+ if (contentType.includes("application/json")) {
89
+ try {
90
+ return JSON.parse(text)
91
+ } catch {
92
+ return text
93
+ }
94
+ }
95
+ return text
96
+ }
97
+
98
+ function pickHeaders(
99
+ headers: Headers,
100
+ allow: readonly string[],
101
+ ): Record<string, string> | undefined {
102
+ const out: Record<string, string> = {}
103
+ const allowed = new Set(allow.map((name) => name.toLowerCase()))
104
+ for (const [key, value] of headers.entries()) {
105
+ if (allowed.has(key.toLowerCase())) out[key.toLowerCase()] = value
106
+ }
107
+ return Object.keys(out).length > 0 ? out : undefined
108
+ }
109
+
110
+ /** Build a capsule from a real `Request`+`Response`, or from plain captured fields. */
111
+ export async function captureIncident(
112
+ request: Request | CapturedRequestInput,
113
+ response: Response | { status: number; body?: unknown },
114
+ options: CaptureIncidentOptions = {},
115
+ ): Promise<IncidentCapsule> {
116
+ const allow = options.headerAllowList ?? DEFAULT_HEADER_ALLOW
117
+ const req: {
118
+ method: string
119
+ path: string
120
+ query?: Record<string, string>
121
+ headers?: Record<string, string>
122
+ body?: unknown
123
+ } = { method: "GET", path: "/" }
124
+ if (request instanceof Request) {
125
+ const url = new URL(request.url)
126
+ const query = Object.fromEntries(url.searchParams.entries())
127
+ const headers = pickHeaders(request.headers, allow)
128
+ const body = await readBody(request)
129
+ req.method = request.method
130
+ req.path = url.pathname
131
+ if (Object.keys(query).length > 0) req.query = query
132
+ if (headers !== undefined) req.headers = headers
133
+ if (body !== undefined) req.body = body
134
+ } else {
135
+ req.method = request.method ?? "GET"
136
+ req.path = request.path
137
+ if (request.query !== undefined) req.query = request.query
138
+ if (request.headers !== undefined) req.headers = request.headers
139
+ if (request.body !== undefined) req.body = request.body
140
+ }
141
+
142
+ let status: number
143
+ let responseBody: unknown
144
+ if (response instanceof Response) {
145
+ status = response.status
146
+ responseBody = await readBody(response)
147
+ } else {
148
+ status = response.status
149
+ responseBody = response.body
150
+ }
151
+
152
+ return {
153
+ id: options.id ?? newId(),
154
+ capturedAt: options.capturedAt ?? new Date().toISOString(),
155
+ request: req,
156
+ response: {
157
+ status,
158
+ ...(responseBody !== undefined ? { bodyShape: shapeOf(responseBody) } : {}),
159
+ },
160
+ ...(options.note !== undefined ? { note: options.note } : {}),
161
+ }
162
+ }
163
+
164
+ function buildRequest(capsule: IncidentCapsule, origin: string): Request {
165
+ const url = new URL(capsule.request.path, origin)
166
+ for (const [key, value] of Object.entries(capsule.request.query ?? {})) {
167
+ url.searchParams.set(key, value)
168
+ }
169
+ const headers = new Headers(capsule.request.headers)
170
+ const hasBody = capsule.request.body !== undefined
171
+ if (hasBody && !headers.has("content-type")) headers.set("content-type", "application/json")
172
+ const init: RequestInit = { method: capsule.request.method, headers }
173
+ if (hasBody) init.body = JSON.stringify(capsule.request.body)
174
+ return new Request(url.toString(), init)
175
+ }
176
+
177
+ export interface ReplayIncidentOptions {
178
+ readonly origin?: string
179
+ /** Also require the response body SHAPE to match the captured shape (not just status). Default false. */
180
+ readonly assertShape?: boolean
181
+ }
182
+
183
+ /** Replay a captured incident against the current app and report whether it reproduces. */
184
+ export async function replayIncident(
185
+ app: AppLike,
186
+ capsule: IncidentCapsule,
187
+ options: ReplayIncidentOptions = {},
188
+ ): Promise<IncidentReplayResult> {
189
+ const origin = options.origin ?? DEFAULT_ORIGIN
190
+ const response = await app.fetch(buildRequest(capsule, origin))
191
+ const statusMatches = response.status === capsule.response.status
192
+
193
+ let shapeMatches: boolean | null = null
194
+ if (options.assertShape === true) {
195
+ const body = await readBody(response)
196
+ shapeMatches = shapesEqual(shapeOf(body), capsule.response.bodyShape ?? undefined)
197
+ }
198
+
199
+ return {
200
+ reproduced: statusMatches && shapeMatches !== false,
201
+ status: response.status,
202
+ expectedStatus: capsule.response.status,
203
+ statusMatches,
204
+ shapeMatches,
205
+ }
206
+ }
207
+
208
+ export class IncidentReplayError extends Error {
209
+ readonly result: IncidentReplayResult
210
+ readonly capsuleId: string
211
+ constructor(capsule: IncidentCapsule, result: IncidentReplayResult) {
212
+ const parts = [`expected status ${result.expectedStatus}, got ${result.status}`]
213
+ if (result.shapeMatches === false) parts.push("response shape diverged")
214
+ super(
215
+ `incident ${capsule.id} (${capsule.request.method} ${capsule.request.path}) no longer reproduces: ${parts.join("; ")}`,
216
+ )
217
+ this.name = "IncidentReplayError"
218
+ this.result = result
219
+ this.capsuleId = capsule.id
220
+ }
221
+ }
222
+
223
+ /** Assert a captured incident still reproduces against the current app. Throws {@link IncidentReplayError}. */
224
+ export async function assertIncidentReplays(
225
+ app: AppLike,
226
+ capsule: IncidentCapsule,
227
+ options: ReplayIncidentOptions = {},
228
+ ): Promise<void> {
229
+ const result = await replayIncident(app, capsule, options)
230
+ if (!result.reproduced) throw new IncidentReplayError(capsule, result)
231
+ }
232
+
233
+ const REDACTED = "<redacted>"
234
+
235
+ /**
236
+ * Redact leaf string values by default (unless the dotted key path is allow-listed). Non-strings are
237
+ * kept — they carry the structure that makes the fixture reproduce — so review the emitted file. This
238
+ * is intentionally aggressive: a committed fixture must not leak PII/secrets.
239
+ */
240
+ export function redactForEmission(value: unknown, allow: ReadonlySet<string>, path = ""): unknown {
241
+ if (typeof value === "string") return allow.has(path) ? value : REDACTED
242
+ if (Array.isArray(value))
243
+ return value.map((item, i) => redactForEmission(item, allow, `${path}[${i}]`))
244
+ if (value !== null && typeof value === "object") {
245
+ const out: Record<string, unknown> = {}
246
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
247
+ out[key] = redactForEmission(val, allow, path === "" ? key : `${path}.${key}`)
248
+ }
249
+ return out
250
+ }
251
+ return value
252
+ }
253
+
254
+ export interface GenerateRegressionTestOptions {
255
+ /** Import specifier for the app under test. Default `"../src/app"`. */
256
+ readonly importPath?: string
257
+ /** Named export of the app. Default `"app"`. */
258
+ readonly appExport?: string
259
+ /** Also assert the response shape. Default false (status-only is the robust regression signal). */
260
+ readonly assertShape?: boolean
261
+ /** Dotted key paths in the request body whose string values are safe to embed verbatim. */
262
+ readonly allow?: readonly string[]
263
+ }
264
+
265
+ /**
266
+ * Emit a committable regression test from a capsule. Request string values are redacted BY DEFAULT with
267
+ * a sanitize banner — replace the `<redacted>` placeholders with safe, reproducing values before you
268
+ * commit. The test asserts the response contract via {@link assertIncidentReplays}.
269
+ */
270
+ export function generateRegressionTest(
271
+ capsule: IncidentCapsule,
272
+ options: GenerateRegressionTestOptions = {},
273
+ ): string {
274
+ const importPath = options.importPath ?? "../src/app"
275
+ const appExport = options.appExport ?? "app"
276
+ const allow = new Set(options.allow ?? [])
277
+ const safeRequest: CapturedRequest = {
278
+ ...capsule.request,
279
+ ...(capsule.request.body !== undefined
280
+ ? { body: redactForEmission(capsule.request.body, allow) }
281
+ : {}),
282
+ }
283
+ const emitted: IncidentCapsule = { ...capsule, request: safeRequest }
284
+ const redacted = JSON.stringify(emitted, null, 2).includes(REDACTED)
285
+
286
+ return `// GENERATED by nifra incident → regression.
287
+ ${redacted ? '// ⚠️ SANITIZE BEFORE COMMITTING: replace every "<redacted>" with a safe, non-PII value that\n// still reproduces the response. A committed fixture must not carry PII or secrets.\n' : ""}// Asserts the response CONTRACT (status${options.assertShape ? " + shape" : ""}) still reproduces.
288
+ import { describe, it } from "bun:test"
289
+ import { assertIncidentReplays, type IncidentCapsule } from "@nifrajs/testing"
290
+ import { ${appExport} } from "${importPath}"
291
+
292
+ const capsule: IncidentCapsule = ${JSON.stringify(emitted, null, 2)}
293
+
294
+ describe("regression: ${capsule.request.method} ${capsule.request.path}", () => {
295
+ it("reproduces status ${capsule.response.status}", async () => {
296
+ await assertIncidentReplays(${appExport}, capsule, { assertShape: ${options.assertShape === true} })
297
+ })
298
+ })
299
+ `
300
+ }
package/src/index.ts CHANGED
@@ -8,5 +8,38 @@
8
8
  * const { client, cookies } = testSession<typeof app>(app)
9
9
  */
10
10
 
11
+ export {
12
+ AdversarialContractError,
13
+ type AdversarialContractOptions,
14
+ type AdversarialContractReport,
15
+ type AdversarialContractResult,
16
+ assertAdversarialContract,
17
+ type ContractCaseContext,
18
+ type ContractCaseKind,
19
+ type ContractCoverageGap,
20
+ type ContractCoverageGapCode,
21
+ type ContractReplay,
22
+ type ContractRuntime,
23
+ type ContractTarget,
24
+ type ContractTestApp,
25
+ type ContractWitness,
26
+ runAdversarialContract,
27
+ } from "./adversarial.ts"
11
28
  export { type CookieJar, cookieJar } from "./cookies.ts"
29
+ export {
30
+ assertIncidentReplays,
31
+ type CapturedRequest,
32
+ type CapturedRequestInput,
33
+ type CaptureIncidentOptions,
34
+ captureIncident,
35
+ type GenerateRegressionTestOptions,
36
+ generateRegressionTest,
37
+ type IncidentCapsule,
38
+ IncidentReplayError,
39
+ type IncidentReplayResult,
40
+ type ReplayIncidentOptions,
41
+ redactForEmission,
42
+ replayIncident,
43
+ shapeOf,
44
+ } from "./incident.ts"
12
45
  export { type AppLike, type TestSession, type TestSessionOptions, testSession } from "./session.ts"