@nifrajs/testing 3.1.0 → 3.2.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,196 @@
1
+ /**
2
+ * Runtime-neutral HTTP contract witnesses.
3
+ *
4
+ * The witness set is deliberately made only of Web `Request`/`Response` values. Runtime suites provide
5
+ * the handler under test - the full Bun server, the compact edge server, or a native Node/Deno adapter -
6
+ * and this module compares the wire contract without importing any runtime-specific implementation.
7
+ */
8
+
9
+ export interface ContractLabHandler {
10
+ fetch(request: Request): Response | Promise<Response>
11
+ }
12
+
13
+ export interface ContractLabServer {
14
+ readonly origin: string
15
+ stop(): void | Promise<void>
16
+ }
17
+
18
+ export interface ContractLabRuntimeAdapter {
19
+ start(app: ContractLabHandler): ContractLabServer | Promise<ContractLabServer>
20
+ }
21
+
22
+ export interface ContractLabWitness {
23
+ readonly id: string
24
+ readonly request: {
25
+ readonly method: string
26
+ readonly path: string
27
+ readonly headers?: Readonly<Record<string, string>>
28
+ readonly body?: string
29
+ }
30
+ readonly expected: {
31
+ readonly status: number
32
+ readonly contentType: "json" | "text"
33
+ readonly headers?: Readonly<Record<string, string>>
34
+ readonly body: unknown
35
+ }
36
+ }
37
+
38
+ /** The shared cross-runtime wire contract. Keep this list small and stable: it is evidence, not a load test. */
39
+ export const contractLabWitnesses: readonly ContractLabWitness[] = Object.freeze([
40
+ {
41
+ id: "params-query-header",
42
+ request: {
43
+ method: "GET",
44
+ path: "/lab/users/alice?tag=one&tag=two",
45
+ headers: { "x-lab": "yes" },
46
+ },
47
+ expected: {
48
+ status: 200,
49
+ contentType: "json",
50
+ body: { id: "alice", tags: ["one", "two"], lab: "yes" },
51
+ },
52
+ },
53
+ {
54
+ id: "json-body",
55
+ request: {
56
+ method: "POST",
57
+ path: "/lab/echo",
58
+ headers: { "content-type": "application/json" },
59
+ body: JSON.stringify({ message: "hello", count: 2 }),
60
+ },
61
+ expected: {
62
+ status: 200,
63
+ contentType: "json",
64
+ body: { message: "hello", count: 2 },
65
+ },
66
+ },
67
+ {
68
+ id: "response-bridge",
69
+ request: { method: "GET", path: "/lab/created" },
70
+ expected: { status: 201, contentType: "text", body: "created" },
71
+ },
72
+ {
73
+ id: "not-found",
74
+ request: { method: "GET", path: "/lab/missing" },
75
+ expected: { status: 404, contentType: "json", body: { ok: false, error: "not_found" } },
76
+ },
77
+ {
78
+ id: "method-not-allowed",
79
+ request: { method: "POST", path: "/lab/users/alice" },
80
+ expected: {
81
+ status: 405,
82
+ contentType: "json",
83
+ headers: { allow: "GET, HEAD" },
84
+ body: { ok: false, error: "method_not_allowed" },
85
+ },
86
+ },
87
+ ])
88
+
89
+ function canonical(value: unknown): string {
90
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
91
+ if (value !== null && typeof value === "object") {
92
+ const record = value as Record<string, unknown>
93
+ return `{${Object.keys(record)
94
+ .sort()
95
+ .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`)
96
+ .join(",")}}`
97
+ }
98
+ return JSON.stringify(value)
99
+ }
100
+
101
+ function witnessRequest(origin: string, witness: ContractLabWitness): Request {
102
+ return new Request(`${origin}${witness.request.path}`, {
103
+ method: witness.request.method,
104
+ headers: witness.request.headers,
105
+ ...(witness.request.body === undefined ? {} : { body: witness.request.body }),
106
+ })
107
+ }
108
+
109
+ async function assertWitnessResponse(
110
+ witness: ContractLabWitness,
111
+ response: Response,
112
+ ): Promise<void> {
113
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? ""
114
+ const actual =
115
+ witness.expected.contentType === "json" ? await response.json() : await response.text()
116
+ const expectedContentType =
117
+ witness.expected.contentType === "json" ? "application/json" : "text/plain"
118
+ const headersMatch = Object.entries(witness.expected.headers ?? {}).every(
119
+ ([name, value]) => response.headers.get(name) === value,
120
+ )
121
+ if (
122
+ response.status !== witness.expected.status ||
123
+ contentType !== expectedContentType ||
124
+ !headersMatch ||
125
+ canonical(actual) !== canonical(witness.expected.body)
126
+ ) {
127
+ throw new Error(
128
+ `contract witness ${witness.id} failed: expected ${witness.expected.status} ` +
129
+ `${expectedContentType} ${canonical(witness.expected.body)}, got ${response.status} ` +
130
+ `${contentType || "(missing content type)"} ${canonical(actual)}`,
131
+ )
132
+ }
133
+ }
134
+
135
+ /** Execute every witness against one runtime and throw a bounded, replayable mismatch. */
136
+ export async function runContractLab(
137
+ handler: ContractLabHandler,
138
+ origin = "http://nifra-contract-lab.invalid",
139
+ ): Promise<void> {
140
+ for (const witness of contractLabWitnesses) {
141
+ await assertWitnessResponse(witness, await handler.fetch(witnessRequest(origin, witness)))
142
+ }
143
+ }
144
+
145
+ /** Execute the same shared witnesses through a real HTTP origin. */
146
+ export async function runContractLabOverHttp(
147
+ origin: string,
148
+ fetcher: typeof fetch = globalThis.fetch,
149
+ ): Promise<void> {
150
+ for (const witness of contractLabWitnesses) {
151
+ await assertWitnessResponse(witness, await fetcher(witnessRequest(origin, witness)))
152
+ }
153
+ }
154
+
155
+ /** Run the shared witnesses through a real HTTP adapter and always release its server. */
156
+ export async function runContractLabThroughAdapter(
157
+ adapter: ContractLabRuntimeAdapter,
158
+ app: ContractLabHandler = createReferenceContractLabHandler(),
159
+ ): Promise<void> {
160
+ const server = await adapter.start(app)
161
+ try {
162
+ await runContractLabOverHttp(server.origin)
163
+ } finally {
164
+ await server.stop()
165
+ }
166
+ }
167
+
168
+ /** A reference Web handler for adapter-only suites. Core and edge suites use their real routers instead. */
169
+ export function createReferenceContractLabHandler(): ContractLabHandler {
170
+ return {
171
+ async fetch(request) {
172
+ const url = new URL(request.url)
173
+ const user = /^\/lab\/users\/([^/]+)$/.exec(url.pathname)
174
+ if (request.method === "GET" && user !== null) {
175
+ return Response.json({
176
+ id: decodeURIComponent(user[1] ?? ""),
177
+ tags: url.searchParams.getAll("tag"),
178
+ lab: request.headers.get("x-lab"),
179
+ })
180
+ }
181
+ if (request.method === "POST" && url.pathname === "/lab/echo") {
182
+ return Response.json(await request.json())
183
+ }
184
+ if (request.method === "GET" && url.pathname === "/lab/created") {
185
+ return new Response("created", { status: 201, headers: { "content-type": "text/plain" } })
186
+ }
187
+ if (url.pathname === "/lab/users/alice") {
188
+ return Response.json(
189
+ { ok: false, error: "method_not_allowed" },
190
+ { status: 405, headers: { allow: "GET, HEAD" } },
191
+ )
192
+ }
193
+ return Response.json({ ok: false, error: "not_found" }, { status: 404 })
194
+ },
195
+ }
196
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,47 @@ export {
25
25
  type ContractWitness,
26
26
  runAdversarialContract,
27
27
  } from "./adversarial.ts"
28
+ export {
29
+ type CertifiableDeploymentAdapter,
30
+ type CertifiableDescriptorAdapter,
31
+ type CertifiableModelGateway,
32
+ deploymentAdapterCertificationProfile,
33
+ deploymentCertificationProfile,
34
+ gatewayCertificationProfile,
35
+ modelGatewayCertificationProfile,
36
+ registryCertificationProfile,
37
+ } from "./agent-certification.ts"
38
+ export {
39
+ type AgentEvalCase,
40
+ type AgentEvalComponentEvidence,
41
+ type AgentEvalCompositionOptions,
42
+ type AgentEvalCompositionReport,
43
+ AgentEvalRegressionError,
44
+ type AgentEvalReport,
45
+ type AgentEvalSuite,
46
+ type AgentEvalSuiteSpec,
47
+ type AgentFailureMatrixCase,
48
+ AgentFailureMatrixError,
49
+ type AgentFailureMatrixKind,
50
+ type AgentFailureMatrixReport,
51
+ assertAgentEval,
52
+ assertAgentEvalBaseline,
53
+ assertAgentFailureMatrix,
54
+ type BaselineComparison,
55
+ type BaselineOptions,
56
+ type CaseComparison,
57
+ type CaseResult,
58
+ type ComparisonCode,
59
+ compareAgentEvalBaseline,
60
+ defineAgentEvalSuite,
61
+ parseRubricVerdict,
62
+ type RubricShape,
63
+ type RubricSpec,
64
+ type RubricVerdict,
65
+ runAgentEvalComposition,
66
+ runAgentFailureMatrix,
67
+ type ScoreTolerance,
68
+ } from "./agent-eval.ts"
28
69
  export {
29
70
  AdapterCertificationError,
30
71
  type AdapterCertificationProfile,
@@ -51,6 +92,17 @@ export {
51
92
  storageAdapterCertificationProfile,
52
93
  verifyAdapterCertification,
53
94
  } from "./certification.ts"
95
+ export {
96
+ type ContractLabHandler,
97
+ type ContractLabRuntimeAdapter,
98
+ type ContractLabServer,
99
+ type ContractLabWitness,
100
+ contractLabWitnesses,
101
+ createReferenceContractLabHandler,
102
+ runContractLab,
103
+ runContractLabOverHttp,
104
+ runContractLabThroughAdapter,
105
+ } from "./contract-lab.ts"
54
106
  export { type CookieJar, cookieJar } from "./cookies.ts"
55
107
  export {
56
108
  createFailureLab,