@nifrajs/testing 3.5.0 → 4.0.2
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 +26 -0
- package/dist/adversarial.d.ts +14 -1
- package/dist/adversarial.d.ts.map +1 -1
- package/dist/adversarial.js +113 -25
- package/dist/adversarial.js.map +1 -1
- package/dist/agent-eval.d.ts.map +1 -1
- package/dist/agent-eval.js +3 -1
- package/dist/agent-eval.js.map +1 -1
- package/dist/certification.d.ts +10 -1
- package/dist/certification.d.ts.map +1 -1
- package/dist/certification.js +85 -50
- package/dist/certification.js.map +1 -1
- package/dist/contract-lab.js +1 -1
- package/dist/contract-lab.js.map +1 -1
- package/dist/data-conformance.d.ts +72 -0
- package/dist/data-conformance.d.ts.map +1 -0
- package/dist/data-conformance.js +198 -0
- package/dist/data-conformance.js.map +1 -0
- package/dist/e2e.d.ts +63 -0
- package/dist/e2e.d.ts.map +1 -0
- package/dist/e2e.js +56 -0
- package/dist/e2e.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +20 -8
- package/src/adversarial.ts +144 -28
- package/src/agent-eval.ts +4 -3
- package/src/certification.ts +82 -44
- package/src/contract-lab.ts +1 -1
- package/src/data-conformance.ts +297 -0
- package/src/e2e.ts +112 -0
- package/src/index.ts +14 -0
|
@@ -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,
|