@nifrajs/testing 3.5.0 → 4.0.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,297 @@
1
+ /**
2
+ * Behavioral conformance checks for durable data adapters.
3
+ *
4
+ * The framework does not own a SQL dialect or a tenant model. This harness therefore accepts a
5
+ * small adapter-specific operation map and verifies the guarantees that are portable across
6
+ * Postgres, SQLite, D1, Durable Objects, and other implementations.
7
+ */
8
+
9
+ export interface DataConformanceScope {
10
+ readonly token: string
11
+ }
12
+
13
+ export interface DataConformancePage<Row, Cursor = string> {
14
+ readonly items: readonly Row[]
15
+ readonly cursor?: Cursor
16
+ }
17
+
18
+ export interface DataConformanceTransaction<Row> {
19
+ write(row: Row): Promise<void>
20
+ }
21
+
22
+ export interface DataAdapterConformanceTarget<Adapter, Row, Snapshot, Cursor = string> {
23
+ /** Return a fresh adapter handle. Two handles must observe shared durable state. */
24
+ readonly createAdapter: (instance: string) => Adapter | Promise<Adapter>
25
+ /** Insert a fixture row under the adapter's tenant/RLS boundary. */
26
+ readonly seed: (adapter: Adapter, scope: DataConformanceScope, row: Row) => Promise<void>
27
+ /** Read rows visible to one scope. */
28
+ readonly read: (adapter: Adapter, scope: DataConformanceScope) => Promise<readonly Row[]>
29
+ /** Perform one cancellable write. */
30
+ readonly write: (
31
+ adapter: Adapter,
32
+ scope: DataConformanceScope,
33
+ row: Row,
34
+ signal: AbortSignal,
35
+ ) => Promise<void>
36
+ /** Execute writes atomically; a rejected callback must roll back all writes. */
37
+ readonly transaction: <T>(
38
+ adapter: Adapter,
39
+ scope: DataConformanceScope,
40
+ run: (transaction: DataConformanceTransaction<Row>) => Promise<T>,
41
+ ) => Promise<T>
42
+ /** Return one cursor page. The cursor is adapter-owned and must be treated as opaque. */
43
+ readonly page: (
44
+ adapter: Adapter,
45
+ scope: DataConformanceScope,
46
+ cursor?: Cursor,
47
+ ) => Promise<DataConformancePage<Row, Cursor>>
48
+ /** Capture and restore a durable snapshot for backup/restore testing. */
49
+ readonly snapshot: (adapter: Adapter) => Promise<Snapshot>
50
+ readonly restore: (adapter: Adapter, snapshot: Snapshot) => Promise<void>
51
+ /** Stable row identity used to detect cross-tenant leaks and pagination duplicates. */
52
+ readonly rowKey: (row: Row) => string
53
+ /** Optional cleanup for an adapter handle. */
54
+ readonly close?: (adapter: Adapter) => Promise<void>
55
+ }
56
+
57
+ export type DataConformanceCheck =
58
+ | "rls-isolation"
59
+ | "transaction-rollback"
60
+ | "cancellation"
61
+ | "pagination"
62
+ | "backup-restore"
63
+ | "multi-instance"
64
+
65
+ export interface DataConformanceFailure {
66
+ readonly check: DataConformanceCheck
67
+ readonly message: string
68
+ }
69
+
70
+ export interface DataAdapterConformanceReport {
71
+ readonly ok: boolean
72
+ readonly checks: Readonly<Record<DataConformanceCheck, "pass" | "fail">>
73
+ readonly failures: readonly DataConformanceFailure[]
74
+ }
75
+
76
+ export class DataAdapterConformanceError extends Error {
77
+ readonly report: DataAdapterConformanceReport
78
+
79
+ constructor(report: DataAdapterConformanceReport) {
80
+ super(
81
+ `data adapter conformance failed: ${report.failures
82
+ .map((failure) => `${failure.check}: ${failure.message}`)
83
+ .join("; ")}`,
84
+ )
85
+ this.name = "DataAdapterConformanceError"
86
+ this.report = report
87
+ }
88
+ }
89
+
90
+ const CHECKS: readonly DataConformanceCheck[] = [
91
+ "rls-isolation",
92
+ "transaction-rollback",
93
+ "cancellation",
94
+ "pagination",
95
+ "backup-restore",
96
+ "multi-instance",
97
+ ]
98
+
99
+ const defaultScopes = Object.freeze({
100
+ left: Object.freeze({ token: "nifra-conformance/tenant-left" }),
101
+ right: Object.freeze({ token: "nifra-conformance/tenant-right" }),
102
+ })
103
+
104
+ const defaultRows = Object.freeze({
105
+ left: Object.freeze({ id: "left" }),
106
+ right: Object.freeze({ id: "right" }),
107
+ rollback: Object.freeze({ id: "rollback" }),
108
+ shared: Object.freeze({ id: "shared" }),
109
+ })
110
+
111
+ function isAbortLike(error: unknown): boolean {
112
+ return (
113
+ (typeof DOMException !== "undefined" &&
114
+ error instanceof DOMException &&
115
+ error.name === "AbortError") ||
116
+ (error instanceof Error && /abort|cancel/i.test(`${error.name} ${error.message}`))
117
+ )
118
+ }
119
+
120
+ async function closeAll<Adapter>(
121
+ target: DataAdapterConformanceTarget<Adapter, unknown, unknown, unknown>,
122
+ adapters: readonly Adapter[],
123
+ ): Promise<void> {
124
+ if (target.close === undefined) return
125
+ await Promise.all(adapters.map((adapter) => target.close!(adapter)))
126
+ }
127
+
128
+ /**
129
+ * Run the portable data-adapter contract and return machine-readable evidence.
130
+ *
131
+ * The harness intentionally uses fresh handles and opaque scope tokens. It does not inspect SQL,
132
+ * tenant columns, transaction objects, provider errors, or credentials.
133
+ */
134
+ export async function runDataAdapterConformance<Adapter, Row, Snapshot, Cursor = string>(
135
+ target: DataAdapterConformanceTarget<Adapter, Row, Snapshot, Cursor>,
136
+ options: {
137
+ readonly scopes?: { readonly left: DataConformanceScope; readonly right: DataConformanceScope }
138
+ readonly rows?: {
139
+ readonly left: Row
140
+ readonly right: Row
141
+ readonly rollback: Row
142
+ readonly shared: Row
143
+ }
144
+ } = {},
145
+ ): Promise<DataAdapterConformanceReport> {
146
+ const scopes = options.scopes ?? defaultScopes
147
+ const rows =
148
+ options.rows ??
149
+ (defaultRows as unknown as {
150
+ readonly left: Row
151
+ readonly right: Row
152
+ readonly rollback: Row
153
+ readonly shared: Row
154
+ })
155
+ const adapters: Adapter[] = []
156
+ const failures: DataConformanceFailure[] = []
157
+ const checks: Record<DataConformanceCheck, "pass" | "fail"> = {
158
+ "rls-isolation": "fail",
159
+ "transaction-rollback": "fail",
160
+ cancellation: "fail",
161
+ pagination: "fail",
162
+ "backup-restore": "fail",
163
+ "multi-instance": "fail",
164
+ }
165
+
166
+ const run = async (check: DataConformanceCheck, action: () => Promise<void>): Promise<void> => {
167
+ try {
168
+ await action()
169
+ checks[check] = "pass"
170
+ } catch {
171
+ failures.push({
172
+ check,
173
+ // Provider errors can contain credentials, SQL, tenant identifiers, or request values.
174
+ // The portable report is safe to serialize and log, so retain only the stable check name.
175
+ message: `${check} conformance check failed`,
176
+ })
177
+ }
178
+ }
179
+
180
+ try {
181
+ const primary = await target.createAdapter("primary")
182
+ const secondary = await target.createAdapter("secondary")
183
+ adapters.push(primary, secondary)
184
+
185
+ await run("rls-isolation", async () => {
186
+ await target.seed(primary, scopes.left, rows.left)
187
+ await target.seed(primary, scopes.right, rows.right)
188
+ const left = await target.read(primary, scopes.left)
189
+ const right = await target.read(primary, scopes.right)
190
+ const leftKeys = new Set(left.map(target.rowKey))
191
+ const rightKeys = new Set(right.map(target.rowKey))
192
+ if (!leftKeys.has(target.rowKey(rows.left)) || leftKeys.has(target.rowKey(rows.right))) {
193
+ throw new Error("left scope can read another scope's row")
194
+ }
195
+ if (!rightKeys.has(target.rowKey(rows.right)) || rightKeys.has(target.rowKey(rows.left))) {
196
+ throw new Error("right scope can read another scope's row")
197
+ }
198
+ })
199
+
200
+ await run("transaction-rollback", async () => {
201
+ const before = new Set((await target.read(primary, scopes.left)).map(target.rowKey))
202
+ let rejectedCallback = false
203
+ try {
204
+ await target.transaction(primary, scopes.left, async (transaction) => {
205
+ await transaction.write(rows.rollback)
206
+ throw new Error("conformance rollback")
207
+ })
208
+ } catch {
209
+ rejectedCallback = true
210
+ }
211
+ if (!rejectedCallback) throw new Error("transaction accepted a rejected callback")
212
+ const after = new Set((await target.read(primary, scopes.left)).map(target.rowKey))
213
+ if (after.has(target.rowKey(rows.rollback)) || [...after].some((key) => !before.has(key))) {
214
+ throw new Error("rejected transaction changed durable state")
215
+ }
216
+ })
217
+
218
+ await run("cancellation", async () => {
219
+ const controller = new AbortController()
220
+ controller.abort()
221
+ try {
222
+ await target.write(primary, scopes.left, rows.rollback, controller.signal)
223
+ } catch (error) {
224
+ if (isAbortLike(error) || controller.signal.aborted) return
225
+ throw error
226
+ }
227
+ throw new Error("cancelled write resolved successfully")
228
+ })
229
+
230
+ await run("pagination", async () => {
231
+ const seen: string[] = []
232
+ const cursors = new Set<string>()
233
+ let cursor: Cursor | undefined
234
+ for (let pageNumber = 0; pageNumber < 100; pageNumber++) {
235
+ const page = await target.page(primary, scopes.left, cursor)
236
+ seen.push(...page.items.map(target.rowKey))
237
+ if (page.cursor === undefined) break
238
+ const next = String(page.cursor)
239
+ if (cursors.has(next)) throw new Error("pagination cursor repeated")
240
+ cursors.add(next)
241
+ cursor = page.cursor
242
+ if (pageNumber === 99) throw new Error("pagination did not terminate")
243
+ }
244
+ if (new Set(seen).size !== seen.length) throw new Error("pagination returned a duplicate row")
245
+ })
246
+
247
+ await run("backup-restore", async () => {
248
+ const snapshot = await target.snapshot(primary)
249
+ await target.seed(primary, scopes.left, rows.rollback)
250
+ await target.restore(primary, snapshot)
251
+ const restored = new Set((await target.read(primary, scopes.left)).map(target.rowKey))
252
+ if (restored.has(target.rowKey(rows.rollback))) {
253
+ throw new Error("restore did not remove post-backup data")
254
+ }
255
+ })
256
+
257
+ await run("multi-instance", async () => {
258
+ await target.seed(primary, scopes.left, rows.shared)
259
+ const visible = new Set((await target.read(secondary, scopes.left)).map(target.rowKey))
260
+ if (!visible.has(target.rowKey(rows.shared))) {
261
+ throw new Error("a second adapter instance cannot observe committed durable state")
262
+ }
263
+ })
264
+ } catch {
265
+ failures.push({
266
+ check: "multi-instance",
267
+ message: "multi-instance conformance check failed",
268
+ })
269
+ } finally {
270
+ try {
271
+ await closeAll(
272
+ target as unknown as DataAdapterConformanceTarget<Adapter, unknown, unknown, unknown>,
273
+ adapters,
274
+ )
275
+ } catch {
276
+ failures.push({ check: "multi-instance", message: "adapter cleanup failed" })
277
+ }
278
+ }
279
+
280
+ const failed = new Set(failures.map((failure) => failure.check))
281
+ for (const check of CHECKS) {
282
+ if (failed.has(check)) checks[check] = "fail"
283
+ }
284
+ return Object.freeze({
285
+ ok: failures.length === 0,
286
+ checks: Object.freeze({ ...checks }),
287
+ failures: Object.freeze([...failures]),
288
+ })
289
+ }
290
+
291
+ export async function assertDataAdapterConformance<Adapter, Row, Snapshot, Cursor = string>(
292
+ target: DataAdapterConformanceTarget<Adapter, Row, Snapshot, Cursor>,
293
+ options: Parameters<typeof runDataAdapterConformance<Adapter, Row, Snapshot, Cursor>>[1] = {},
294
+ ): Promise<void> {
295
+ const report = await runDataAdapterConformance(target, options)
296
+ if (!report.ok) throw new DataAdapterConformanceError(report)
297
+ }
package/src/e2e.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Browser-test glue: an ephemeral server plus typed URLs plus the session handoff - everything
3
+ * nifra-specific about driving a real browser, and nothing else. This is deliberately NOT a test
4
+ * runner (bring Playwright or Vitest Browser for the browser itself): API flows already run faster
5
+ * in-process through {@link testSession}, so this module covers only what in-process cannot - a
6
+ * real socket, real navigation, real rendering.
7
+ *
8
+ * ```ts
9
+ * import { chromium } from "@playwright/test"
10
+ * import { e2eUrl, serveTestApp } from "@nifrajs/testing/e2e"
11
+ * import { testSession } from "@nifrajs/testing"
12
+ * import { app } from "../src/app"
13
+ *
14
+ * const { cookies } = testSession<typeof app>(app)
15
+ * await api.auth.login.post(credentials) // in-process login; jar captures the session
16
+ *
17
+ * const www = await serveTestApp(app)
18
+ * const browser = await chromium.launch()
19
+ * const page = await browser.newPage({
20
+ * extraHTTPHeaders: { Cookie: cookies.header() }, // the session crosses into the browser
21
+ * })
22
+ * await page.goto(e2eUrl<typeof app>(www.baseUrl, "/dashboard")) // typo'd path won't compile
23
+ * await www.stop()
24
+ * ```
25
+ */
26
+ import type { Server } from "@nifrajs/core/server"
27
+ import { isSameOriginPath } from "@nifrajs/core/server"
28
+ import type { NodeServer, ServeOptions } from "@nifrajs/node"
29
+
30
+ /** The minimal shape a nifra `server()` app satisfies - its own `fetch`. */
31
+ export interface E2EApp {
32
+ fetch(request: Request): Response | Promise<Response>
33
+ }
34
+
35
+ /** Every route path the app declares - constrains `e2eUrl`, so a wrong path is a type error. */
36
+ export type E2EPaths<App> = App extends Server<infer R, infer _Ctx> ? keyof R & string : never
37
+
38
+ export interface ServeTestAppOptions {
39
+ /** Port to bind. Default `0` (ephemeral - read the real one off {@link ServedTestApp.baseUrl}). */
40
+ readonly port?: number
41
+ /** Interface to bind. Default `"127.0.0.1"`. */
42
+ readonly hostname?: string
43
+ }
44
+
45
+ export interface ServedTestApp {
46
+ /** Origin serving the app, e.g. `"http://127.0.0.1:43127"`. Hand it to {@link e2eUrl}. */
47
+ readonly baseUrl: string
48
+ /** Stop accepting connections and drain in-flight requests. Idempotent. */
49
+ readonly stop: () => Promise<void>
50
+ }
51
+
52
+ /**
53
+ * Serve an app on an ephemeral port for one browser test. The server is `@nifrajs/node`
54
+ * (an optional peer - install it in devDependencies); when it is absent this throws a plain
55
+ * error naming the missing package instead of a module-resolution crash.
56
+ */
57
+ export async function serveTestApp(
58
+ app: E2EApp,
59
+ options: ServeTestAppOptions = {},
60
+ ): Promise<ServedTestApp> {
61
+ let serve: (app: E2EApp, options: ServeOptions) => Promise<NodeServer>
62
+ try {
63
+ ;({ serve } = await import("@nifrajs/node"))
64
+ } catch {
65
+ throw new Error(
66
+ "@nifrajs/testing/e2e: serveTestApp needs the @nifrajs/node package - add it to devDependencies",
67
+ )
68
+ }
69
+ const hostname = options.hostname ?? "127.0.0.1"
70
+ const running = await serve(app, { port: options.port ?? 0, hostname })
71
+ return {
72
+ baseUrl: `http://${hostname}:${running.port}`,
73
+ stop: () => running.stop(),
74
+ }
75
+ }
76
+
77
+ /** `path` on the test app's origin, refused unless it is a same-origin absolute path. */
78
+ function onTestAppOrigin(baseUrl: string, path: string, label: string): URL {
79
+ const base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`)
80
+ const resolved = new URL(path, base)
81
+ // The origin compare also holds if the path rule ever admits a form the URL parser re-reads.
82
+ if (!isSameOriginPath(path) || resolved.origin !== base.origin) {
83
+ throw new TypeError(`@nifrajs/testing/e2e: ${label} must be a same-origin absolute path`)
84
+ }
85
+ return resolved
86
+ }
87
+
88
+ /**
89
+ * Join `baseUrl` and a declared route path. The path is checked against the app's registry, so
90
+ * `page.goto(e2eUrl<typeof app>(www.baseUrl, "/dashbord"))` fails typecheck instead of 404ing
91
+ * mid-suite - the same guarantee `formFor` gives form fields.
92
+ */
93
+ export function e2eUrl<App>(baseUrl: string, path: E2EPaths<App>): string {
94
+ return onTestAppOrigin(baseUrl, path, "path").href
95
+ }
96
+
97
+ const WEB_SOCKET_BASES: ReadonlySet<string> = new Set(["http:", "https:", "ws:", "wss:"])
98
+
99
+ /**
100
+ * Build a real WebSocket URL for an adapter-backed test server. The path uses the same-origin guard as
101
+ * {@link e2eUrl}; `http`/`https` are translated to `ws`/`wss` so callers cannot accidentally dial an
102
+ * HTTP URL with a WebSocket client or escape the test server origin.
103
+ */
104
+ export function e2eWebSocket<App>(baseUrl: string, path: E2EPaths<App>): string {
105
+ const resolved = onTestAppOrigin(baseUrl, path, "WebSocket path")
106
+ if (!WEB_SOCKET_BASES.has(resolved.protocol)) {
107
+ throw new TypeError("@nifrajs/testing/e2e: unsupported WebSocket base URL protocol")
108
+ }
109
+ resolved.protocol =
110
+ resolved.protocol === "https:" || resolved.protocol === "wss:" ? "wss:" : "ws:"
111
+ return resolved.href
112
+ }
package/src/index.ts CHANGED
@@ -14,10 +14,12 @@ export {
14
14
  type AdversarialContractReport,
15
15
  type AdversarialContractResult,
16
16
  assertAdversarialContract,
17
+ type ContractAuthContext,
17
18
  type ContractCaseContext,
18
19
  type ContractCaseKind,
19
20
  type ContractCoverageGap,
20
21
  type ContractCoverageGapCode,
22
+ type ContractExpectedResponse,
21
23
  type ContractReplay,
22
24
  type ContractRuntime,
23
25
  type ContractTarget,
@@ -105,6 +107,18 @@ export {
105
107
  runContractLabThroughAdapter,
106
108
  } from "./contract-lab.ts"
107
109
  export { type CookieJar, cookieJar } from "./cookies.ts"
110
+ export {
111
+ assertDataAdapterConformance,
112
+ DataAdapterConformanceError,
113
+ type DataAdapterConformanceReport,
114
+ type DataAdapterConformanceTarget,
115
+ type DataConformanceCheck,
116
+ type DataConformanceFailure,
117
+ type DataConformancePage,
118
+ type DataConformanceScope,
119
+ type DataConformanceTransaction,
120
+ runDataAdapterConformance,
121
+ } from "./data-conformance.ts"
108
122
  export {
109
123
  createFailureLab,
110
124
  type FailureDirective,