@nifrajs/testing 1.6.0 → 1.9.1
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 +75 -4
- package/dist/adversarial.d.ts +129 -0
- package/dist/adversarial.d.ts.map +1 -0
- package/dist/adversarial.js +763 -0
- package/dist/adversarial.js.map +1 -0
- package/dist/incident.d.ts +97 -0
- package/dist/incident.d.ts.map +1 -0
- package/dist/incident.js +214 -0
- package/dist/incident.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 +5 -6
- package/src/adversarial.ts +1130 -0
- package/src/incident.ts +300 -0
- package/src/index.ts +33 -0
package/src/incident.ts
ADDED
|
@@ -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"
|