@glubean/sdk 0.1.39 → 0.2.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.
Files changed (78) hide show
  1. package/dist/configure.d.ts +4 -21
  2. package/dist/configure.d.ts.map +1 -1
  3. package/dist/configure.js +19 -145
  4. package/dist/configure.js.map +1 -1
  5. package/dist/contract-core.d.ts +98 -0
  6. package/dist/contract-core.d.ts.map +1 -0
  7. package/dist/contract-core.js +749 -0
  8. package/dist/contract-core.js.map +1 -0
  9. package/dist/contract-http/adapter.d.ts +52 -0
  10. package/dist/contract-http/adapter.d.ts.map +1 -0
  11. package/dist/contract-http/adapter.js +650 -0
  12. package/dist/contract-http/adapter.js.map +1 -0
  13. package/dist/contract-http/factory.d.ts +32 -0
  14. package/dist/contract-http/factory.d.ts.map +1 -0
  15. package/dist/contract-http/factory.js +83 -0
  16. package/dist/contract-http/factory.js.map +1 -0
  17. package/dist/contract-http/flow-helpers.d.ts +12 -0
  18. package/dist/contract-http/flow-helpers.d.ts.map +1 -0
  19. package/dist/contract-http/flow-helpers.js +34 -0
  20. package/dist/contract-http/flow-helpers.js.map +1 -0
  21. package/dist/contract-http/index.d.ts +16 -0
  22. package/dist/contract-http/index.d.ts.map +1 -0
  23. package/dist/contract-http/index.js +15 -0
  24. package/dist/contract-http/index.js.map +1 -0
  25. package/dist/contract-http/markdown.d.ts +10 -0
  26. package/dist/contract-http/markdown.d.ts.map +1 -0
  27. package/dist/contract-http/markdown.js +21 -0
  28. package/dist/contract-http/markdown.js.map +1 -0
  29. package/dist/contract-http/openapi.d.ts +15 -0
  30. package/dist/contract-http/openapi.d.ts.map +1 -0
  31. package/dist/contract-http/openapi.js +38 -0
  32. package/dist/contract-http/openapi.js.map +1 -0
  33. package/dist/contract-http/types.d.ts +252 -0
  34. package/dist/contract-http/types.d.ts.map +1 -0
  35. package/dist/contract-http/types.js +13 -0
  36. package/dist/contract-http/types.js.map +1 -0
  37. package/dist/contract-types.d.ts +420 -467
  38. package/dist/contract-types.d.ts.map +1 -1
  39. package/dist/contract-types.js +16 -4
  40. package/dist/contract-types.js.map +1 -1
  41. package/dist/each-builder.d.ts +244 -0
  42. package/dist/each-builder.d.ts.map +1 -0
  43. package/dist/each-builder.js +268 -0
  44. package/dist/each-builder.js.map +1 -0
  45. package/dist/index.d.ts +30 -513
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +33 -826
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal.d.ts +1 -0
  50. package/dist/internal.d.ts.map +1 -1
  51. package/dist/internal.js +1 -0
  52. package/dist/internal.js.map +1 -1
  53. package/dist/runtime-carrier.d.ts +142 -0
  54. package/dist/runtime-carrier.d.ts.map +1 -0
  55. package/dist/runtime-carrier.js +148 -0
  56. package/dist/runtime-carrier.js.map +1 -0
  57. package/dist/session.d.ts.map +1 -1
  58. package/dist/session.js +2 -1
  59. package/dist/session.js.map +1 -1
  60. package/dist/test-builder.d.ts +249 -0
  61. package/dist/test-builder.d.ts.map +1 -0
  62. package/dist/test-builder.js +265 -0
  63. package/dist/test-builder.js.map +1 -0
  64. package/dist/test-extend.d.ts +59 -0
  65. package/dist/test-extend.d.ts.map +1 -0
  66. package/dist/test-extend.js +111 -0
  67. package/dist/test-extend.js.map +1 -0
  68. package/dist/test-utils.d.ts +39 -0
  69. package/dist/test-utils.d.ts.map +1 -0
  70. package/dist/test-utils.js +91 -0
  71. package/dist/test-utils.js.map +1 -0
  72. package/dist/types.d.ts +41 -122
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/dist/contract.d.ts +0 -64
  76. package/dist/contract.d.ts.map +0 -1
  77. package/dist/contract.js +0 -793
  78. package/dist/contract.js.map +0 -1
package/dist/contract.js DELETED
@@ -1,793 +0,0 @@
1
- /**
2
- * contract.http() — declarative API contract testing.
3
- *
4
- * Spec in, Test[] out. Each case becomes a runnable Test.
5
- * The return value extends Array<Test> so runner/resolve handles it natively.
6
- */
7
- import { registerTest } from "./internal.js";
8
- // =============================================================================
9
- // HttpContractImpl — extends Array<Test> with contract-level methods
10
- // =============================================================================
11
- /**
12
- * Create an HttpContract — a Test[] with contract-level properties.
13
- * Uses Object.assign on a plain array to avoid class-extends-Array pitfalls.
14
- */
15
- function createHttpContract(id, endpoint, tests, request, description, feature, caseSchemas, instanceName, security, deprecated, extensions, requestContentType, requestHeaders, requestExample, requestExamples) {
16
- const arr = [...tests];
17
- const asSteps = () => {
18
- return (b) => {
19
- for (const t of arr) {
20
- if (t.fn) {
21
- b.step(t.meta.name ?? t.meta.id, async (ctx) => {
22
- await t.fn(ctx);
23
- });
24
- }
25
- }
26
- return b;
27
- };
28
- };
29
- const asStep = (caseKey) => {
30
- const target = caseKey
31
- ? arr.find((t) => t.meta.id.endsWith(`.${caseKey}`))
32
- : arr.find((t) => t.fn !== undefined && !t.meta.deferred && !t.meta.deprecated);
33
- if (!target)
34
- throw new Error(`Case "${caseKey ?? "default"}" not found in contract "${id}"`);
35
- return (b) => {
36
- b.step(target.meta.name ?? target.meta.id, async (ctx) => {
37
- await target.fn(ctx);
38
- });
39
- return b;
40
- };
41
- };
42
- return Object.assign(arr, {
43
- id, endpoint, request, description, feature,
44
- instanceName, security, deprecated, extensions,
45
- requestContentType, requestHeaders, requestExample, requestExamples,
46
- _caseSchemas: caseSchemas,
47
- asSteps, asStep,
48
- });
49
- }
50
- // =============================================================================
51
- // Parse endpoint string
52
- // =============================================================================
53
- function parseEndpoint(endpoint) {
54
- const spaceIdx = endpoint.indexOf(" ");
55
- if (spaceIdx === -1)
56
- return { method: "GET", path: endpoint };
57
- return {
58
- method: endpoint.slice(0, spaceIdx).toUpperCase(),
59
- path: endpoint.slice(spaceIdx + 1),
60
- };
61
- }
62
- /**
63
- * Extract the string value from a ParamValue (string or { value } object).
64
- */
65
- function extractParamValue(v) {
66
- if (typeof v === "string")
67
- return v;
68
- if (v && typeof v === "object" && "value" in v) {
69
- return String(v.value);
70
- }
71
- return String(v);
72
- }
73
- /**
74
- * Convert a params/query Record<string, ParamValue> to Record<string, string>
75
- * by extracting `.value` from ParamValue objects.
76
- */
77
- function flattenParamValues(params) {
78
- if (!params)
79
- return undefined;
80
- const result = {};
81
- for (const [key, value] of Object.entries(params)) {
82
- result[key] = extractParamValue(value);
83
- }
84
- return result;
85
- }
86
- function resolveParams(path, params) {
87
- if (!params)
88
- return path;
89
- let resolved = path;
90
- for (const [key, value] of Object.entries(params)) {
91
- resolved = resolved.replace(`:${key}`, encodeURIComponent(value));
92
- }
93
- return resolved;
94
- }
95
- /**
96
- * Field names on the structured RequestSpec form. If any of these exist on `req`,
97
- * we treat it as structured; otherwise we treat it as a bare SchemaLike body shorthand.
98
- * This is the authoritative disambiguator — do NOT rely on probing SchemaLike methods
99
- * (safeParse/parse), because SchemaLike allows either one to be present.
100
- */
101
- const STRUCTURED_REQUEST_FIELDS = ["body", "contentType", "headers", "example", "examples"];
102
- /**
103
- * Normalize RequestSpec to a structured form { body, contentType, headers, example, examples }.
104
- * Accepts either bare SchemaLike (treated as JSON body) or already-structured object.
105
- *
106
- * Disambiguation rule: structured form is recognized by presence of any known
107
- * structured field. Otherwise the input is a SchemaLike (either safeParse-only
108
- * or parse-only — both are valid).
109
- */
110
- function normalizeRequest(req) {
111
- if (!req || typeof req !== "object")
112
- return undefined;
113
- // Structured form: has any of the known structured fields
114
- const hasStructuredField = STRUCTURED_REQUEST_FIELDS.some((f) => f in req);
115
- if (hasStructuredField) {
116
- return req;
117
- }
118
- // Otherwise treat as SchemaLike (safeParse-only or parse-only)
119
- return { body: req };
120
- }
121
- /**
122
- * Build request options based on content type. Supports:
123
- * - application/json (default) — body → requestOptions.json
124
- * - multipart/form-data — body (FormData | object) → requestOptions.body
125
- * - application/x-www-form-urlencoded — body (URLSearchParams | object) → requestOptions.body
126
- * - text/plain, application/octet-stream — body → requestOptions.body (string/binary)
127
- */
128
- function buildRequestBodyOptions(body, contentType) {
129
- if (body === undefined)
130
- return {};
131
- const ct = (contentType ?? "application/json").toLowerCase();
132
- if (ct.startsWith("application/json")) {
133
- return { json: body };
134
- }
135
- if (ct.startsWith("multipart/form-data")) {
136
- // If body is already FormData, pass through. Otherwise, convert object to FormData.
137
- if (typeof FormData !== "undefined" && body instanceof FormData) {
138
- return { body };
139
- }
140
- if (body && typeof body === "object") {
141
- const fd = new FormData();
142
- for (const [k, v] of Object.entries(body)) {
143
- if (v instanceof Blob || v instanceof File) {
144
- fd.append(k, v);
145
- }
146
- else {
147
- fd.append(k, String(v));
148
- }
149
- }
150
- return { body: fd };
151
- }
152
- return { body };
153
- }
154
- if (ct.startsWith("application/x-www-form-urlencoded")) {
155
- if (body instanceof URLSearchParams)
156
- return { body };
157
- if (body && typeof body === "object") {
158
- const params = new URLSearchParams();
159
- for (const [k, v] of Object.entries(body)) {
160
- params.append(k, String(v));
161
- }
162
- return { body: params };
163
- }
164
- return { body };
165
- }
166
- // text/plain, application/octet-stream, other — raw pass-through
167
- // Set Content-Type header if not already present (handled via headers in caller)
168
- return { body };
169
- }
170
- /**
171
- * Normalize response headers to lowercase keys + preserve multi-value shape.
172
- * HTTP spec: header names are case-insensitive. Some headers (Set-Cookie) can have multiple values.
173
- */
174
- function normalizeResponseHeaders(headers) {
175
- const result = {};
176
- if (!headers)
177
- return result;
178
- // Handle Headers object (fetch / ky response headers)
179
- if (typeof headers.forEach === "function" && typeof headers.get === "function") {
180
- // Use .forEach which handles multi-value headers by concatenation
181
- headers.forEach((value, key) => {
182
- const lowerKey = key.toLowerCase();
183
- // Set-Cookie is the common multi-value case; split comma for best-effort
184
- if (lowerKey === "set-cookie") {
185
- const existing = result[lowerKey];
186
- const newValue = typeof existing === "string" ? [existing, value] : [...(existing ?? []), value];
187
- result[lowerKey] = newValue;
188
- }
189
- else {
190
- result[lowerKey] = value;
191
- }
192
- });
193
- return result;
194
- }
195
- // Handle plain object
196
- if (typeof headers === "object") {
197
- for (const [key, value] of Object.entries(headers)) {
198
- const lowerKey = key.toLowerCase();
199
- if (Array.isArray(value)) {
200
- result[lowerKey] = value.map(String);
201
- }
202
- else if (value != null) {
203
- result[lowerKey] = String(value);
204
- }
205
- }
206
- }
207
- return result;
208
- }
209
- // =============================================================================
210
- // Build a Test from a single case
211
- // =============================================================================
212
- function buildCaseTest(contractId, caseKey, endpoint, c, spec, instanceName, security) {
213
- const { method, path } = parseEndpoint(endpoint);
214
- const contractTags = spec.tags ? (Array.isArray(spec.tags) ? spec.tags : [spec.tags]) : [];
215
- const caseTags = c.tags ?? [];
216
- const allTags = [...contractTags, ...caseTags];
217
- const testId = `${contractId}.${caseKey}`;
218
- const testName = `${contractId} — ${caseKey}`;
219
- // Contract-level deprecated propagates to cases (case value wins if both set)
220
- const effectiveDeprecated = c.deprecated ?? spec.deprecated;
221
- // Auto-imply: non-headless requires → defaultRun: "opt-in"
222
- const requires = c.requires ?? "headless";
223
- const defaultRun = c.defaultRun ?? (requires !== "headless" ? "opt-in" : "always");
224
- // Auto-tag: requires:browser, requires:out-of-band, default-run:opt-in
225
- const runtimeTags = [];
226
- if (requires !== "headless")
227
- runtimeTags.push(`requires:${requires}`);
228
- if (defaultRun === "opt-in")
229
- runtimeTags.push("default-run:opt-in");
230
- const finalTags = [...allTags, ...runtimeTags];
231
- const meta = {
232
- id: testId,
233
- name: testName,
234
- description: c.description,
235
- tags: finalTags.length > 0 ? finalTags : undefined,
236
- deferred: c.deferred,
237
- deprecated: effectiveDeprecated,
238
- requires,
239
- defaultRun,
240
- };
241
- const fn = async (ctx) => {
242
- // 1. Deprecated → skip (takes precedence over deferred)
243
- if (effectiveDeprecated) {
244
- ctx.skip(`deprecated: ${effectiveDeprecated}`);
245
- }
246
- // 2. Deferred → skip
247
- if (c.deferred) {
248
- ctx.skip(c.deferred);
249
- }
250
- // 2. Setup
251
- const state = c.setup ? await c.setup(ctx) : undefined;
252
- try {
253
- // 3. Resolve client
254
- const client = (c.client ?? spec.client);
255
- if (!client) {
256
- throw new Error(`No HTTP client provided for case "${caseKey}". ` +
257
- `Set "client" on the case or on the contract spec.`);
258
- }
259
- // 4. Resolve params and path (flatten ParamValue objects to strings)
260
- const rawParams = typeof c.params === "function" ? c.params(state) : c.params;
261
- const params = flattenParamValues(rawParams);
262
- const resolvedPath = resolveParams(path, params);
263
- // 5. Build request options
264
- const requestOptions = {};
265
- const body = typeof c.body === "function" ? c.body(state) : c.body;
266
- // Resolve content type: case override > contract default > application/json
267
- const normalizedRequest = normalizeRequest(spec.request);
268
- const effectiveContentType = c.contentType ?? normalizedRequest?.contentType ?? "application/json";
269
- // Dispatch body serialization based on content type
270
- if (body !== undefined) {
271
- Object.assign(requestOptions, buildRequestBodyOptions(body, effectiveContentType));
272
- }
273
- const headers = typeof c.headers === "function" ? c.headers(state) : c.headers;
274
- if (headers)
275
- requestOptions.headers = headers;
276
- if (c.query) {
277
- const rawQuery = typeof c.query === "function" ? c.query(state) : c.query;
278
- requestOptions.searchParams = flattenParamValues(rawQuery);
279
- }
280
- requestOptions.throwHttpErrors = false;
281
- // 6. Send request
282
- const methodLower = method.toLowerCase();
283
- let res;
284
- try {
285
- res = await client[methodLower](resolvedPath, requestOptions);
286
- }
287
- catch (err) {
288
- // Enhance timeout errors with configured timeout value
289
- if (err instanceof Error && err.name === "TimeoutError") {
290
- const timeoutMs = client._configuredTimeout ?? 10000;
291
- throw new Error(`${err.message} (timeout: ${timeoutMs}ms)`);
292
- }
293
- throw err;
294
- }
295
- // 7. Assert status
296
- ctx.expect(res).toHaveStatus(c.expect.status);
297
- // 7b. Validate response headers (if schema provided)
298
- if (c.expect.headers) {
299
- const normalizedHeaders = normalizeResponseHeaders(res.headers);
300
- ctx.validate(normalizedHeaders, c.expect.headers, `${testId} response headers`);
301
- }
302
- // 8. Parse response + validate schema
303
- let parsed;
304
- if (c.expect.schema) {
305
- const body = await res.json();
306
- const validated = ctx.validate(body, c.expect.schema, `${testId} response`);
307
- parsed = (validated !== undefined ? validated : body);
308
- }
309
- else if (c.verify) {
310
- // No schema but verify needs the body — parse as raw JSON
311
- parsed = (await res.json());
312
- }
313
- else {
314
- parsed = undefined;
315
- }
316
- // 9. Verify callback
317
- if (c.verify) {
318
- await c.verify(ctx, parsed);
319
- }
320
- }
321
- finally {
322
- // 10. Teardown (always)
323
- if (c.teardown) {
324
- await c.teardown(ctx, state);
325
- }
326
- }
327
- };
328
- // Lifecycle normalization: deprecated > deferred > active
329
- const lifecycle = c.deprecated ? "deprecated" :
330
- c.deferred ? "deferred" :
331
- "active";
332
- const severity = c.severity ?? "warning";
333
- // Register to global registry with protocol-agnostic contract metadata
334
- const registryMeta = {
335
- target: endpoint,
336
- protocol: "http",
337
- caseKey,
338
- lifecycle,
339
- severity,
340
- hasSchema: !!c.expect.schema,
341
- instanceName,
342
- protocolMeta: {
343
- ...(security != null ? { security } : {}),
344
- expect: { status: c.expect.status },
345
- },
346
- };
347
- registerTest({
348
- id: testId,
349
- name: testName,
350
- type: "simple",
351
- tags: finalTags.length > 0 ? finalTags : undefined,
352
- groupId: contractId,
353
- requires,
354
- defaultRun,
355
- contract: registryMeta,
356
- });
357
- return { meta, type: "simple", fn };
358
- }
359
- // =============================================================================
360
- // contract.http()
361
- // =============================================================================
362
- function contractHttp(id, spec, instanceName, security) {
363
- const tests = [];
364
- const caseSchemas = {};
365
- for (const [caseKey, caseSpec] of Object.entries(spec.cases)) {
366
- tests.push(buildCaseTest(id, caseKey, spec.endpoint, caseSpec, spec, instanceName, security));
367
- // Resolve effective requires/defaultRun (same logic as buildCaseTest)
368
- const effectiveRequires = caseSpec.requires ?? "headless";
369
- const effectiveDefaultRun = caseSpec.defaultRun ?? (effectiveRequires !== "headless" ? "opt-in" : "always");
370
- // Effective deprecated: case wins, fall back to contract-level
371
- const effectiveDeprecated = caseSpec.deprecated ?? spec.deprecated;
372
- // Lifecycle normalization: deprecated > deferred > active
373
- const lifecycle = effectiveDeprecated ? "deprecated" :
374
- caseSpec.deferred ? "deferred" :
375
- "active";
376
- // Extract per-param schemas (only from object-shaped ParamValues, not string shorthand)
377
- const paramSchemas = extractParamMetaSchemas(caseSpec.params);
378
- const querySchemas = extractParamMetaSchemas(caseSpec.query);
379
- // Merge case-level extensions over contract-level (spec.extensions is already defaults+contract merged)
380
- const caseExtensions = mergeExtensions(spec.extensions, caseSpec.extensions);
381
- caseSchemas[caseKey] = {
382
- expectStatus: caseSpec.expect.status,
383
- responseSchema: caseSpec.expect.schema,
384
- responseHeaders: caseSpec.expect.headers,
385
- responseContentType: caseSpec.expect.contentType,
386
- example: caseSpec.expect.example,
387
- examples: caseSpec.expect.examples,
388
- paramSchemas,
389
- querySchemas,
390
- description: caseSpec.description,
391
- deferred: caseSpec.deferred,
392
- deprecated: effectiveDeprecated,
393
- severity: caseSpec.severity,
394
- lifecycle,
395
- requires: effectiveRequires,
396
- defaultRun: effectiveDefaultRun,
397
- extensions: caseExtensions,
398
- };
399
- }
400
- // Normalize request (SchemaLike or RequestSpec object → structured fields)
401
- const normalizedReq = normalizeRequest(spec.request);
402
- return createHttpContract(id, spec.endpoint, tests, normalizedReq?.body, spec.description, spec.feature, caseSchemas, instanceName, security, spec.deprecated, spec.extensions, normalizedReq?.contentType, normalizedReq?.headers, normalizedReq?.example, normalizedReq?.examples);
403
- }
404
- /**
405
- * Extract per-param schema metadata from a params/query object.
406
- * Only collects entries where the ParamValue is an object (has `.value` + optional schema/description).
407
- * String shorthand values produce no metadata.
408
- */
409
- function extractParamMetaSchemas(params) {
410
- if (!params || typeof params === "function")
411
- return undefined;
412
- const result = {};
413
- let hasAny = false;
414
- for (const [key, val] of Object.entries(params)) {
415
- if (val && typeof val === "object" && !Array.isArray(val) && "value" in val) {
416
- const pv = val;
417
- if (pv.schema !== undefined || pv.description !== undefined || pv.required !== undefined || pv.deprecated !== undefined) {
418
- result[key] = {
419
- schema: pv.schema,
420
- description: pv.description,
421
- required: pv.required,
422
- deprecated: pv.deprecated,
423
- };
424
- hasAny = true;
425
- }
426
- }
427
- }
428
- return hasAny ? result : undefined;
429
- }
430
- class FlowBuilder {
431
- _id;
432
- _defaultClient;
433
- _tags;
434
- _requires;
435
- _defaultRun;
436
- _steps = [];
437
- _setupFn;
438
- _teardownFn;
439
- constructor(id, options) {
440
- this._id = id;
441
- this._defaultClient = options?.client;
442
- this._tags = options?.tags;
443
- this._requires = options?.requires;
444
- this._defaultRun = options?.defaultRun;
445
- }
446
- setup(fn) {
447
- this._setupFn = fn;
448
- return this;
449
- }
450
- teardown(fn) {
451
- this._teardownFn = fn;
452
- return this;
453
- }
454
- http(name, spec) {
455
- const { method, path } = parseEndpoint(spec.endpoint);
456
- const expectedStatus = spec.expect.status;
457
- const stepFn = async (ctx, state) => {
458
- // Resolve client
459
- const client = (spec.client ?? this._defaultClient);
460
- if (!client) {
461
- throw new Error(`No HTTP client for flow step "${name}". Set client on the step or flow.`);
462
- }
463
- // Resolve params/query/body from state
464
- const params = typeof spec.params === "function" ? spec.params(state) : spec.params;
465
- const resolvedPath = resolveParams(path, params);
466
- const query = typeof spec.query === "function" ? spec.query(state) : spec.query;
467
- const body = typeof spec.body === "function" ? spec.body(state) : spec.body;
468
- // Build request options
469
- const opts = { throwHttpErrors: false };
470
- if (body !== undefined)
471
- opts.json = body;
472
- const headers = typeof spec.headers === "function" ? spec.headers(state) : spec.headers;
473
- if (headers)
474
- opts.headers = headers;
475
- if (query)
476
- opts.searchParams = query;
477
- // Send request
478
- const methodLower = method.toLowerCase();
479
- let res;
480
- try {
481
- res = await client[methodLower](resolvedPath, opts);
482
- }
483
- catch (err) {
484
- if (err instanceof Error && err.name === "TimeoutError") {
485
- const timeoutMs = client._configuredTimeout ?? 10000;
486
- throw new Error(`${err.message} (timeout: ${timeoutMs}ms)`);
487
- }
488
- throw err;
489
- }
490
- // Assert status
491
- ctx.expect(res).toHaveStatus(expectedStatus);
492
- // Validate schema + parse response
493
- let parsed;
494
- if (spec.expect.schema) {
495
- const jsonBody = await res.json();
496
- const validated = ctx.validate(jsonBody, spec.expect.schema, `${this._id}/${name} response`);
497
- parsed = validated !== undefined ? validated : jsonBody;
498
- }
499
- else if (spec.verify || spec.returns) {
500
- parsed = await res.json();
501
- }
502
- // Verify callback
503
- if (spec.verify) {
504
- await spec.verify(ctx, parsed);
505
- }
506
- // State evolution
507
- if (spec.returns) {
508
- return spec.returns(parsed, state);
509
- }
510
- return state;
511
- };
512
- this._steps.push({
513
- meta: { name, endpoint: spec.endpoint, expectStatus: expectedStatus },
514
- fn: stepFn,
515
- });
516
- return this;
517
- }
518
- build() {
519
- // Auto-imply: non-headless requires → defaultRun: "opt-in"
520
- const requires = this._requires ?? "headless";
521
- const defaultRun = this._defaultRun ?? (requires !== "headless" ? "opt-in" : "always");
522
- // Auto-tag
523
- const baseTags = this._tags ?? [];
524
- const runtimeTags = [];
525
- if (requires !== "headless")
526
- runtimeTags.push(`requires:${requires}`);
527
- if (defaultRun === "opt-in")
528
- runtimeTags.push("default-run:opt-in");
529
- const finalTags = [...baseTags, ...runtimeTags];
530
- const test = {
531
- meta: {
532
- id: this._id,
533
- name: this._id,
534
- tags: finalTags.length > 0 ? finalTags : undefined,
535
- requires,
536
- defaultRun,
537
- },
538
- type: "steps",
539
- setup: this._setupFn,
540
- teardown: this._teardownFn,
541
- steps: this._steps.map((s) => ({
542
- meta: { name: s.meta.name },
543
- fn: s.fn,
544
- })),
545
- };
546
- const flowStepsMeta = this._steps.map((s) => s.meta);
547
- // Register to global registry with flow metadata
548
- registerTest({
549
- id: this._id,
550
- name: this._id,
551
- type: "steps",
552
- tags: finalTags.length > 0 ? finalTags : undefined,
553
- requires,
554
- defaultRun,
555
- steps: this._steps.map((s) => ({ name: s.meta.name })),
556
- hasSetup: !!this._setupFn,
557
- hasTeardown: !!this._teardownFn,
558
- flow: {
559
- steps: flowStepsMeta,
560
- },
561
- });
562
- const asSteps = () => {
563
- const steps = test.steps;
564
- const setupFn = this._setupFn;
565
- const teardownFn = this._teardownFn;
566
- return (b) => {
567
- // Note: setup and teardown are injected as regular steps.
568
- // This means teardown does NOT have finally semantics —
569
- // if an earlier step fails, teardown won't run.
570
- // For guaranteed cleanup, use standalone flow or add
571
- // .teardown() on the outer test builder.
572
- if (setupFn) {
573
- b.step(`${this._id} [setup]`, async (ctx) => {
574
- return setupFn(ctx);
575
- });
576
- }
577
- // Inject flow steps
578
- for (const s of steps) {
579
- b.step(s.meta.name, async (ctx, state) => {
580
- return s.fn(ctx, state);
581
- });
582
- }
583
- // Inject teardown as last step if flow has one
584
- if (teardownFn) {
585
- b.step(`${this._id} [teardown]`, async (ctx, state) => {
586
- await teardownFn(ctx, state);
587
- return state;
588
- });
589
- }
590
- return b;
591
- };
592
- };
593
- return Object.assign(test, { flowId: this._id, flowSteps: flowStepsMeta, asSteps });
594
- }
595
- }
596
- function contractFlow(id, options) {
597
- return new FlowBuilder(id, options);
598
- }
599
- // =============================================================================
600
- // contract namespace + register()
601
- // =============================================================================
602
- const _adapters = new Map();
603
- // =============================================================================
604
- // HTTP contract factory — contract.http.with("name", defaults)
605
- // =============================================================================
606
- /**
607
- * Merge instance defaults with per-contract spec.
608
- * Tags are additive (concat), other fields: spec overrides defaults.
609
- */
610
- function mergeHttpDefaults(defaults, spec) {
611
- if (!defaults)
612
- return spec;
613
- const mergedTags = [
614
- ...(defaults.tags ?? []),
615
- ...(spec.tags ?? []),
616
- ];
617
- // Merge extensions: defaults < contract (contract key overrides defaults key)
618
- const mergedExtensions = mergeExtensions(defaults.extensions, spec.extensions);
619
- return {
620
- ...spec,
621
- client: spec.client ?? defaults.client,
622
- feature: spec.feature ?? defaults.feature,
623
- tags: mergedTags.length > 0 ? mergedTags : undefined,
624
- extensions: mergedExtensions,
625
- };
626
- }
627
- /**
628
- * Merge two Extensions records. Right wins on key conflict.
629
- * Returns undefined if result would be empty.
630
- */
631
- function mergeExtensions(base, override) {
632
- if (!base && !override)
633
- return undefined;
634
- const merged = { ...(base ?? {}), ...(override ?? {}) };
635
- return Object.keys(merged).length > 0
636
- ? merged
637
- : undefined;
638
- }
639
- /**
640
- * Create an HTTP contract factory with `.with()` support.
641
- * The factory is callable (same signature as contractHttp) and chainable.
642
- */
643
- /**
644
- * Create an HTTP contract factory. If `defaults` is provided (via .with()),
645
- * the factory is callable. If not, only .with() is available — direct
646
- * contract.http("id", spec) is not supported.
647
- */
648
- function createHttpFactory(defaults) {
649
- const factory = (id, spec) => {
650
- if (!defaults?._name) {
651
- throw new Error(`contract.http("${id}", spec) is not supported. ` +
652
- `Use contract.http.with("name", { client }) first to create a scoped instance, ` +
653
- `then call instance("${id}", spec).`);
654
- }
655
- const merged = mergeHttpDefaults(defaults, spec);
656
- return contractHttp(id, merged, defaults._name, defaults.security);
657
- };
658
- factory.with = (name, more) => {
659
- const mergedTags = [...(defaults?.tags ?? []), ...(more.tags ?? [])];
660
- const mergedExtensions = mergeExtensions(defaults?.extensions, more.extensions);
661
- return createHttpFactory({
662
- ...defaults,
663
- ...more,
664
- tags: mergedTags.length > 0 ? mergedTags : undefined,
665
- extensions: mergedExtensions,
666
- _name: name,
667
- });
668
- };
669
- return factory;
670
- }
671
- /**
672
- * The contract namespace.
673
- *
674
- * - `contract.http.with("name", defaults)` — create scoped HTTP factory
675
- * - `contract.flow(id, options)` — declarative flow builder
676
- * - `contract.register(protocol, adapter)` — plugin extension point
677
- * - `contract[protocol](id, spec)` — available after register()
678
- */
679
- export const contract = {
680
- http: createHttpFactory(),
681
- flow: contractFlow,
682
- register(protocol, adapter) {
683
- if (protocol === "http" || protocol === "flow" || protocol === "register") {
684
- throw new Error(`Cannot register reserved protocol "${protocol}"`);
685
- }
686
- _adapters.set(protocol, adapter);
687
- // Dynamically attach contract[protocol]()
688
- contract[protocol] = (id, spec) => {
689
- // Get projection from adapter v2
690
- const projection = adapter.project(spec);
691
- // Validate: no duplicate keys in projected cases
692
- const projKeyList = projection.cases.map(c => c.key);
693
- const projKeySet = new Set(projKeyList);
694
- if (projKeySet.size !== projKeyList.length) {
695
- const dupes = projKeyList.filter((k, i) => projKeyList.indexOf(k) !== i);
696
- throw new Error(`contract.register("${protocol}"): project() returned duplicate case key(s): ${[...new Set(dupes)].join(", ")}. ` +
697
- `Each projected case key must be unique.`);
698
- }
699
- // Validate 1:1 key invariant between projection and spec.cases
700
- const specKeys = new Set(Object.keys(spec.cases ?? {}));
701
- for (const key of projKeySet) {
702
- if (!specKeys.has(key)) {
703
- throw new Error(`contract.register("${protocol}"): project() returned case "${key}" not present in spec.cases. ` +
704
- `Projected cases must 1:1 match spec.cases keys.`);
705
- }
706
- }
707
- for (const key of specKeys) {
708
- if (!projKeySet.has(key)) {
709
- throw new Error(`contract.register("${protocol}"): spec.cases has "${key}" but project() did not return it. ` +
710
- `Projected cases must 1:1 match spec.cases keys.`);
711
- }
712
- }
713
- const cases = spec.cases ?? {};
714
- const contractTags = spec.tags ? (Array.isArray(spec.tags) ? spec.tags : [spec.tags]) : [];
715
- // Build a lookup from projection cases by key
716
- const projCaseMap = new Map(projection.cases.map(c => [c.key, c]));
717
- const tests = Object.entries(cases).map(([caseKey, caseSpec]) => {
718
- const testId = `${id}.${caseKey}`;
719
- const testName = `${id} — ${caseKey}`;
720
- const caseTags = caseSpec.tags ?? [];
721
- const allTags = [...contractTags, ...caseTags];
722
- const projCase = projCaseMap.get(caseKey);
723
- // Derive runtime requires/defaultRun from projection (authoritative)
724
- // with case spec as fallback, mirroring HTTP path logic
725
- const requires = projCase.requires ?? caseSpec.requires ?? "headless";
726
- const defaultRun = projCase.defaultRun ?? caseSpec.defaultRun ?? (requires !== "headless" ? "opt-in" : "always");
727
- // Runtime tags from requires/defaultRun
728
- const runtimeTags = [];
729
- if (requires !== "headless")
730
- runtimeTags.push(`requires:${requires}`);
731
- if (defaultRun === "opt-in")
732
- runtimeTags.push("default-run:opt-in");
733
- const finalTags = [...allTags, ...runtimeTags];
734
- // Derive skip from projected lifecycle (authoritative), not just caseSpec fields
735
- const skipDeprecated = projCase.lifecycle === "deprecated"
736
- ? `deprecated: ${projCase.deprecatedReason ?? caseSpec.deprecated ?? "deprecated"}`
737
- : caseSpec.deprecated ? `deprecated: ${caseSpec.deprecated}` : undefined;
738
- const skipDeferred = projCase.lifecycle === "deferred"
739
- ? (projCase.deferredReason ?? caseSpec.deferred ?? "deferred")
740
- : caseSpec.deferred;
741
- const testDef = {
742
- meta: {
743
- id: testId,
744
- name: testName,
745
- description: projCase.description,
746
- tags: finalTags.length > 0 ? finalTags : undefined,
747
- deferred: skipDeferred,
748
- deprecated: skipDeprecated ? (projCase.deprecatedReason ?? caseSpec.deprecated ?? "deprecated") : undefined,
749
- requires,
750
- defaultRun,
751
- },
752
- type: "simple",
753
- fn: async (ctx) => {
754
- if (skipDeprecated)
755
- ctx.skip(skipDeprecated);
756
- if (skipDeferred)
757
- ctx.skip(skipDeferred);
758
- await adapter.execute(ctx, caseSpec, spec);
759
- },
760
- };
761
- registerTest({
762
- id: testId,
763
- name: testName,
764
- type: "simple",
765
- tags: finalTags.length > 0 ? finalTags : undefined,
766
- groupId: id,
767
- requires,
768
- defaultRun,
769
- contract: {
770
- target: projection.target,
771
- protocol: projection.protocol,
772
- caseKey,
773
- lifecycle: projCase.lifecycle,
774
- severity: projCase.severity,
775
- hasSchema: projCase.responseSchema != null,
776
- instanceName: projection.instanceName,
777
- protocolMeta: {
778
- ...(projection.protocolMeta ?? {}),
779
- ...(projCase.protocolExpect ? { expect: projCase.protocolExpect } : {}),
780
- ...(projCase.protocolMeta ?? {}),
781
- },
782
- },
783
- });
784
- return testDef;
785
- });
786
- // Inject contract id into projection (adapter doesn't know the user-supplied id)
787
- const enrichedProjection = { ...projection, id };
788
- // Return ProtocolContract — Test[] with _projection carrier
789
- return Object.assign(tests, { _projection: enrichedProjection });
790
- };
791
- },
792
- };
793
- //# sourceMappingURL=contract.js.map