@glubean/sdk 0.1.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.
package/dist/expect.js ADDED
@@ -0,0 +1,763 @@
1
+ /**
2
+ * Fluent assertion API for Glubean tests.
3
+ *
4
+ * Inspired by Jest/Vitest `expect()` but designed for API testing:
5
+ * - **Soft-by-default**: Failed assertions emit events but do NOT throw.
6
+ * All assertions run and all failures are collected.
7
+ * - **`.orFail()` guard**: Opt-in hard failure for when subsequent code
8
+ * depends on the assertion passing.
9
+ * - **`.not` negation**: Negate any assertion via a getter.
10
+ *
11
+ * The class is framework-agnostic — it accepts an `emitter` callback
12
+ * that routes assertion results into the runner's event pipeline.
13
+ *
14
+ * @module
15
+ */
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Produce a short, human-readable representation of a value for error messages.
21
+ * Truncates long output to keep assertion messages scannable.
22
+ */
23
+ export function inspect(value, maxLen = 64) {
24
+ if (value === null)
25
+ return "null";
26
+ if (value === undefined)
27
+ return "undefined";
28
+ if (typeof value === "string") {
29
+ const escaped = JSON.stringify(value);
30
+ if (escaped.length <= maxLen)
31
+ return escaped;
32
+ // Truncate: keep opening quote, trim content, append ..."
33
+ // Result: "aaa..." which is maxLen chars total
34
+ return escaped.slice(0, maxLen - 4) + '..."';
35
+ }
36
+ if (typeof value === "number" || typeof value === "boolean") {
37
+ return String(value);
38
+ }
39
+ if (typeof value === "bigint")
40
+ return `${value}n`;
41
+ if (typeof value === "symbol")
42
+ return value.toString();
43
+ if (typeof value === "function") {
44
+ return `[Function: ${value.name || "anonymous"}]`;
45
+ }
46
+ if (value instanceof RegExp)
47
+ return value.toString();
48
+ if (value instanceof Date)
49
+ return value.toISOString();
50
+ try {
51
+ const json = JSON.stringify(value);
52
+ return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json;
53
+ }
54
+ catch {
55
+ return String(value);
56
+ }
57
+ }
58
+ /**
59
+ * Deep equality check.
60
+ * Handles primitives, arrays, plain objects, Date, RegExp, Map, Set, null, undefined.
61
+ * Safely handles circular references via a seen-pairs set.
62
+ */
63
+ export function deepEqual(a, b, seen) {
64
+ if (Object.is(a, b))
65
+ return true;
66
+ if (a === null ||
67
+ b === null ||
68
+ typeof a !== "object" ||
69
+ typeof b !== "object") {
70
+ return false;
71
+ }
72
+ // Circular reference guard: if we've already started comparing this exact
73
+ // pair of object references, treat them as equal to avoid infinite recursion.
74
+ if (!seen)
75
+ seen = new Set();
76
+ const sentinel = { a, b };
77
+ for (const s of seen) {
78
+ const pair = s;
79
+ if (pair.a === a && pair.b === b)
80
+ return true;
81
+ }
82
+ seen.add(sentinel);
83
+ // Date
84
+ if (a instanceof Date && b instanceof Date) {
85
+ return a.getTime() === b.getTime();
86
+ }
87
+ // RegExp
88
+ if (a instanceof RegExp && b instanceof RegExp) {
89
+ return a.source === b.source && a.flags === b.flags;
90
+ }
91
+ // Map
92
+ if (a instanceof Map && b instanceof Map) {
93
+ if (a.size !== b.size)
94
+ return false;
95
+ for (const [key, val] of a) {
96
+ if (!b.has(key) || !deepEqual(val, b.get(key), seen))
97
+ return false;
98
+ }
99
+ return true;
100
+ }
101
+ // Set
102
+ if (a instanceof Set && b instanceof Set) {
103
+ if (a.size !== b.size)
104
+ return false;
105
+ for (const val of a) {
106
+ if (!b.has(val))
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ // Array
112
+ if (Array.isArray(a) && Array.isArray(b)) {
113
+ if (a.length !== b.length)
114
+ return false;
115
+ for (let i = 0; i < a.length; i++) {
116
+ if (!deepEqual(a[i], b[i], seen))
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+ // Plain objects
122
+ const keysA = Object.keys(a);
123
+ const keysB = Object.keys(b);
124
+ if (keysA.length !== keysB.length)
125
+ return false;
126
+ for (const key of keysA) {
127
+ if (!Object.prototype.hasOwnProperty.call(b, key) ||
128
+ !deepEqual(a[key], b[key], seen)) {
129
+ return false;
130
+ }
131
+ }
132
+ return true;
133
+ }
134
+ /**
135
+ * Check whether `subset` is a subset-match of `obj` (partial deep equality).
136
+ * Every key in `subset` must exist in `obj` and deeply equal the value.
137
+ */
138
+ function matchesObject(obj, subset) {
139
+ for (const key of Object.keys(subset)) {
140
+ if (!Object.prototype.hasOwnProperty.call(obj, key))
141
+ return false;
142
+ const sv = subset[key];
143
+ const ov = obj[key];
144
+ if (typeof sv === "object" &&
145
+ sv !== null &&
146
+ !Array.isArray(sv) &&
147
+ typeof ov === "object" &&
148
+ ov !== null &&
149
+ !Array.isArray(ov)) {
150
+ if (!matchesObject(ov, sv)) {
151
+ return false;
152
+ }
153
+ }
154
+ else if (!deepEqual(ov, sv)) {
155
+ return false;
156
+ }
157
+ }
158
+ return true;
159
+ }
160
+ /**
161
+ * Resolve a dot-separated property path on an object.
162
+ * Returns `{ found: true, value }` or `{ found: false }`.
163
+ */
164
+ function resolvePath(obj, path) {
165
+ const parts = path.split(".");
166
+ let current = obj;
167
+ for (const part of parts) {
168
+ if (current === null ||
169
+ current === undefined ||
170
+ typeof current !== "object") {
171
+ return { found: false };
172
+ }
173
+ if (!Object.prototype.hasOwnProperty.call(current, part)) {
174
+ return { found: false };
175
+ }
176
+ current = current[part];
177
+ }
178
+ return { found: true, value: current };
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Sentinel error for .orFail()
182
+ // ---------------------------------------------------------------------------
183
+ /**
184
+ * Error thrown by `.orFail()` when the preceding assertion failed.
185
+ * Caught by the harness as a hard failure.
186
+ */
187
+ export class ExpectFailError extends Error {
188
+ constructor(message) {
189
+ super(message);
190
+ this.name = "ExpectFailError";
191
+ }
192
+ }
193
+ /**
194
+ * Fluent assertion object returned by `ctx.expect(actual)`.
195
+ *
196
+ * Every terminal method (e.g. `toBe`, `toContain`) emits an assertion event
197
+ * and returns `this` for optional chaining with `.orFail()`.
198
+ *
199
+ * **Soft-by-default**: failed assertions are recorded but execution continues.
200
+ *
201
+ * **Custom matchers**: Use `Expectation.extend()` to add domain-specific
202
+ * assertions. Combine with `CustomMatchers` declaration merging for full
203
+ * type safety — see {@link CustomMatchers} for details.
204
+ *
205
+ * **Assertion messages**: Every matcher accepts an optional `message` string as
206
+ * its last argument. When provided, it is prepended to the auto-generated
207
+ * message: `"GET /users status: expected 401 to be 200"`. This makes failures
208
+ * far more actionable in Trace Viewer, CI logs, and MCP tool output.
209
+ *
210
+ * @example Basic assertions
211
+ * ```ts
212
+ * ctx.expect(res.status).toBe(200);
213
+ * ctx.expect(body.users).toHaveLength(3);
214
+ * ctx.expect(body).toMatchObject({ success: true });
215
+ * ```
216
+ *
217
+ * @example With descriptive messages (recommended)
218
+ * ```ts
219
+ * ctx.expect(res.status).toBe(200, "GET /users status");
220
+ * ctx.expect(body.users).toHaveLength(3, "user list count");
221
+ * ctx.expect(res).toHaveHeader("content-type", /json/, "response content type");
222
+ * ```
223
+ *
224
+ * @example Guard + message
225
+ * ```ts
226
+ * ctx.expect(res.status).toBe(200, "POST /orders").orFail();
227
+ * const body = await res.json(); // safe — status was 200
228
+ * ```
229
+ */
230
+ export class Expectation {
231
+ /**
232
+ * The actual value being asserted on.
233
+ * `protected` (not `#private`) so that `Expectation.extend()` prototype
234
+ * patching can access it at runtime.
235
+ */
236
+ _actual;
237
+ _negated;
238
+ _emit;
239
+ /** Tracks whether the last assertion in this chain passed. */
240
+ _lastPassed = true;
241
+ _lastMessage = "";
242
+ constructor(actual, emit, negated = false) {
243
+ this._actual = actual;
244
+ this._emit = emit;
245
+ this._negated = negated;
246
+ }
247
+ // -------------------------------------------------------------------------
248
+ // Modifiers
249
+ // -------------------------------------------------------------------------
250
+ /**
251
+ * Negate the next assertion.
252
+ *
253
+ * @example
254
+ * ctx.expect(body.banned).not.toBe(true);
255
+ * ctx.expect(body.roles).not.toContain("superadmin");
256
+ */
257
+ get not() {
258
+ return new Expectation(this._actual, this._emit, !this._negated);
259
+ }
260
+ /**
261
+ * If the preceding assertion failed, throw immediately to abort the test.
262
+ * Use this for "guard" assertions where subsequent code depends on the value.
263
+ *
264
+ * @example
265
+ * ctx.expect(res.status).toBe(200).orFail();
266
+ * const body = await res.json(); // safe — status was 200
267
+ */
268
+ orFail() {
269
+ if (!this._lastPassed) {
270
+ throw new ExpectFailError(this._lastMessage);
271
+ }
272
+ return this;
273
+ }
274
+ // -------------------------------------------------------------------------
275
+ // Custom matchers
276
+ // -------------------------------------------------------------------------
277
+ /**
278
+ * Register custom assertion matchers on the `Expectation` prototype.
279
+ *
280
+ * Each matcher is a pure function that receives the actual value and any
281
+ * extra arguments, and returns a `MatcherResult`. The SDK automatically
282
+ * handles `.not` negation and `.orFail()` chaining.
283
+ *
284
+ * **Isolation**: Each test file runs in its own Deno subprocess, so
285
+ * prototype mutations from `Expectation.extend()` do not leak between files.
286
+ *
287
+ * **Type safety**: To get full TypeScript support for custom matchers,
288
+ * augment the {@link CustomMatchers} interface via declaration merging.
289
+ * See {@link CustomMatchers} for a complete example.
290
+ *
291
+ * @param matchers Record of matcher name → matcher function
292
+ * @throws If a matcher name conflicts with an existing method
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * // Step 1: Declare types (in your test file or shared .d.ts)
297
+ * declare module "@glubean/sdk/expect" {
298
+ * interface CustomMatchers<T> {
299
+ * toBeEven(): Expectation<T>;
300
+ * toBeWithinRange(min: number, max: number): Expectation<T>;
301
+ * }
302
+ * }
303
+ *
304
+ * // Step 2: Register runtime implementations
305
+ * import { Expectation } from "@glubean/sdk/expect";
306
+ *
307
+ * Expectation.extend({
308
+ * toBeEven: (actual) => ({
309
+ * passed: typeof actual === "number" && actual % 2 === 0,
310
+ * message: "to be even",
311
+ * actual,
312
+ * }),
313
+ * toBeWithinRange: (actual, min, max) => ({
314
+ * passed: typeof actual === "number" && actual >= (min as number) && actual <= (max as number),
315
+ * message: `to be within [${min}, ${max}]`,
316
+ * actual,
317
+ * expected: `[${min}, ${max}]`,
318
+ * }),
319
+ * });
320
+ *
321
+ * // Step 3: Use — fully typed
322
+ * ctx.expect(count).toBeEven();
323
+ * ctx.expect(count).not.toBeEven();
324
+ * ctx.expect(score).toBeWithinRange(0, 100).orFail();
325
+ * ```
326
+ */
327
+ static extend(matchers) {
328
+ for (const [name, fn] of Object.entries(matchers)) {
329
+ if (name in Expectation.prototype) {
330
+ throw new Error(`Matcher "${name}" already exists on Expectation. ` +
331
+ `Choose a different name to avoid conflicts with built-in matchers.`);
332
+ }
333
+ Expectation.prototype[name] = function (...args) {
334
+ // Access protected members via the instance.
335
+ const self = this;
336
+ const result = fn(self._actual, ...args);
337
+ return self._report(result.passed, result.message, result.actual, result.expected);
338
+ };
339
+ }
340
+ }
341
+ // -------------------------------------------------------------------------
342
+ // Internal emit helper
343
+ // -------------------------------------------------------------------------
344
+ /**
345
+ * Emit an assertion result.
346
+ * `protected` so that `Expectation.extend()` prototype patching can call it.
347
+ *
348
+ * @param label Optional human-readable context prepended to the message
349
+ * (e.g. `"GET /users status"` → `"GET /users status: expected 401 to be 200"`).
350
+ */
351
+ _report(rawPassed, message, actual, expected, label) {
352
+ const passed = this._negated ? !rawPassed : rawPassed;
353
+ const prefix = this._negated ? "not " : "";
354
+ const autoMessage = `expected ${inspect(this._actual)} ${prefix}${message}`;
355
+ const fullMessage = label ? `${label}: ${autoMessage}` : autoMessage;
356
+ this._lastPassed = passed;
357
+ this._lastMessage = fullMessage;
358
+ this._emit({ passed, message: fullMessage, actual, expected });
359
+ return this;
360
+ }
361
+ // -------------------------------------------------------------------------
362
+ // Equality
363
+ // -------------------------------------------------------------------------
364
+ /**
365
+ * Strict equality (`Object.is`).
366
+ *
367
+ * @param expected The value to compare against
368
+ * @param message Optional context prepended to the assertion message
369
+ *
370
+ * @example ctx.expect(res.status).toBe(200, "GET /users status");
371
+ */
372
+ toBe(expected, message) {
373
+ return this._report(Object.is(this._actual, expected), `to be ${inspect(expected)}`, this._actual, expected, message);
374
+ }
375
+ /**
376
+ * Deep equality.
377
+ *
378
+ * @param expected The value to compare against
379
+ * @param message Optional context prepended to the assertion message
380
+ *
381
+ * @example ctx.expect(body).toEqual({ id: 1, name: "Alice" }, "response body");
382
+ */
383
+ toEqual(expected, message) {
384
+ return this._report(deepEqual(this._actual, expected), `to equal ${inspect(expected)}`, this._actual, expected, message);
385
+ }
386
+ // -------------------------------------------------------------------------
387
+ // Type / truthiness
388
+ // -------------------------------------------------------------------------
389
+ /**
390
+ * Check the runtime type via `typeof`.
391
+ *
392
+ * @param expected The expected type string
393
+ * @param message Optional context prepended to the assertion message
394
+ *
395
+ * @example ctx.expect(body.name).toBeType("string", "user name type");
396
+ */
397
+ toBeType(expected, message) {
398
+ const actualType = typeof this._actual;
399
+ return this._report(actualType === expected, `to be type ${inspect(expected)}`, actualType, expected, message);
400
+ }
401
+ /**
402
+ * Check that the value is truthy.
403
+ *
404
+ * @param message Optional context prepended to the assertion message
405
+ *
406
+ * @example ctx.expect(body.active).toBeTruthy("user active flag");
407
+ */
408
+ toBeTruthy(message) {
409
+ return this._report(!!this._actual, "to be truthy", this._actual, undefined, message);
410
+ }
411
+ /**
412
+ * Check that the value is falsy.
413
+ *
414
+ * @param message Optional context prepended to the assertion message
415
+ *
416
+ * @example ctx.expect(body.deleted).toBeFalsy("user should not be deleted");
417
+ */
418
+ toBeFalsy(message) {
419
+ return this._report(!this._actual, "to be falsy", this._actual, undefined, message);
420
+ }
421
+ /**
422
+ * Check that the value is `null`.
423
+ *
424
+ * @param message Optional context prepended to the assertion message
425
+ *
426
+ * @example ctx.expect(body.avatar).toBeNull("deleted user avatar");
427
+ */
428
+ toBeNull(message) {
429
+ return this._report(this._actual === null, "to be null", this._actual, null, message);
430
+ }
431
+ /**
432
+ * Check that the value is `undefined`.
433
+ *
434
+ * @param message Optional context prepended to the assertion message
435
+ *
436
+ * @example ctx.expect(body.nickname).toBeUndefined();
437
+ */
438
+ toBeUndefined(message) {
439
+ return this._report(this._actual === undefined, "to be undefined", this._actual, undefined, message);
440
+ }
441
+ /**
442
+ * Check that the value is not `undefined`.
443
+ *
444
+ * @param message Optional context prepended to the assertion message
445
+ *
446
+ * @example ctx.expect(body.id).toBeDefined("response should include id");
447
+ */
448
+ toBeDefined(message) {
449
+ return this._report(this._actual !== undefined, "to be defined", this._actual, undefined, message);
450
+ }
451
+ // -------------------------------------------------------------------------
452
+ // Numeric comparisons
453
+ // -------------------------------------------------------------------------
454
+ /**
455
+ * Check that the value is greater than `n`.
456
+ *
457
+ * @param n The lower bound (exclusive)
458
+ * @param message Optional context prepended to the assertion message
459
+ *
460
+ * @example ctx.expect(body.age).toBeGreaterThan(0, "user age");
461
+ */
462
+ toBeGreaterThan(n, message) {
463
+ return this._report(this._actual > n, `to be greater than ${n}`, this._actual, `> ${n}`, message);
464
+ }
465
+ /**
466
+ * Check that the value is greater than or equal to `n`.
467
+ *
468
+ * @param n The lower bound (inclusive)
469
+ * @param message Optional context prepended to the assertion message
470
+ *
471
+ * @example ctx.expect(body.items.length).toBeGreaterThanOrEqual(1, "result count");
472
+ */
473
+ toBeGreaterThanOrEqual(n, message) {
474
+ return this._report(this._actual >= n, `to be greater than or equal to ${n}`, this._actual, `>= ${n}`, message);
475
+ }
476
+ /**
477
+ * Check that the value is less than `n`.
478
+ *
479
+ * @param n The upper bound (exclusive)
480
+ * @param message Optional context prepended to the assertion message
481
+ *
482
+ * @example ctx.expect(latency).toBeLessThan(500, "response latency ms");
483
+ */
484
+ toBeLessThan(n, message) {
485
+ return this._report(this._actual < n, `to be less than ${n}`, this._actual, `< ${n}`, message);
486
+ }
487
+ /**
488
+ * Check that the value is less than or equal to `n`.
489
+ *
490
+ * @param n The upper bound (inclusive)
491
+ * @param message Optional context prepended to the assertion message
492
+ *
493
+ * @example ctx.expect(res.status).toBeLessThanOrEqual(299, "GET /users status range");
494
+ */
495
+ toBeLessThanOrEqual(n, message) {
496
+ return this._report(this._actual <= n, `to be less than or equal to ${n}`, this._actual, `<= ${n}`, message);
497
+ }
498
+ /**
499
+ * Check that the value is within `[min, max]` (inclusive).
500
+ *
501
+ * @param min Lower bound (inclusive)
502
+ * @param max Upper bound (inclusive)
503
+ * @param message Optional context prepended to the assertion message
504
+ *
505
+ * @example ctx.expect(body.score).toBeWithin(0, 100, "user score range");
506
+ */
507
+ toBeWithin(min, max, message) {
508
+ const val = this._actual;
509
+ return this._report(val >= min && val <= max, `to be within [${min}, ${max}]`, this._actual, `[${min}, ${max}]`, message);
510
+ }
511
+ // -------------------------------------------------------------------------
512
+ // Collection / string
513
+ // -------------------------------------------------------------------------
514
+ /**
515
+ * Check that the value has the expected `length`.
516
+ *
517
+ * @param expected The expected length
518
+ * @param message Optional context prepended to the assertion message
519
+ *
520
+ * @example ctx.expect(body.users).toHaveLength(3, "user list count");
521
+ */
522
+ toHaveLength(expected, message) {
523
+ const actual = this._actual?.length;
524
+ return this._report(actual === expected, `to have length ${expected}`, actual, expected, message);
525
+ }
526
+ /**
527
+ * Check that an array or string contains the given item/substring.
528
+ *
529
+ * @param item The item or substring to search for
530
+ * @param message Optional context prepended to the assertion message
531
+ *
532
+ * @example
533
+ * ctx.expect(body.roles).toContain("admin", "user roles");
534
+ * ctx.expect(body.name).toContain("Ali");
535
+ */
536
+ toContain(item, message) {
537
+ let found;
538
+ if (Array.isArray(this._actual)) {
539
+ found = this._actual.some((el) => deepEqual(el, item));
540
+ }
541
+ else if (typeof this._actual === "string") {
542
+ found = this._actual.includes(item);
543
+ }
544
+ else {
545
+ found = false;
546
+ }
547
+ return this._report(found, `to contain ${inspect(item)}`, this._actual, item, message);
548
+ }
549
+ /**
550
+ * Check that a string matches a regex or includes a substring.
551
+ *
552
+ * @param pattern Regex or substring to match
553
+ * @param message Optional context prepended to the assertion message
554
+ *
555
+ * @example
556
+ * ctx.expect(body.email).toMatch(/@example\.com$/, "email domain");
557
+ * ctx.expect(body.name).toMatch("Alice");
558
+ */
559
+ toMatch(pattern, message) {
560
+ const actual = this._actual;
561
+ const passed = pattern instanceof RegExp ? pattern.test(actual) : actual?.includes(pattern);
562
+ return this._report(!!passed, `to match ${inspect(pattern)}`, this._actual, pattern instanceof RegExp ? pattern.toString() : pattern, message);
563
+ }
564
+ /**
565
+ * Check that a string starts with the given prefix.
566
+ *
567
+ * @param prefix The expected prefix
568
+ * @param message Optional context prepended to the assertion message
569
+ *
570
+ * @example
571
+ * ctx.expect(body.id).toStartWith("usr_", "user id prefix");
572
+ * ctx.expect(url).toStartWith("https://");
573
+ */
574
+ toStartWith(prefix, message) {
575
+ const actual = this._actual;
576
+ return this._report(typeof actual === "string" && actual.startsWith(prefix), `to start with ${inspect(prefix)}`, this._actual, prefix, message);
577
+ }
578
+ /**
579
+ * Check that a string ends with the given suffix.
580
+ *
581
+ * @param suffix The expected suffix
582
+ * @param message Optional context prepended to the assertion message
583
+ *
584
+ * @example
585
+ * ctx.expect(body.email).toEndWith("@example.com", "email format");
586
+ * ctx.expect(body.filename).toEndWith(".json");
587
+ */
588
+ toEndWith(suffix, message) {
589
+ const actual = this._actual;
590
+ return this._report(typeof actual === "string" && actual.endsWith(suffix), `to end with ${inspect(suffix)}`, this._actual, suffix, message);
591
+ }
592
+ /**
593
+ * Partial deep match — every key in `subset` must exist and match in the actual value.
594
+ *
595
+ * @param subset The expected subset of properties
596
+ * @param message Optional context prepended to the assertion message
597
+ *
598
+ * @example ctx.expect(body).toMatchObject({ success: true }, "create user response");
599
+ */
600
+ toMatchObject(subset, message) {
601
+ const passed = typeof this._actual === "object" &&
602
+ this._actual !== null &&
603
+ matchesObject(this._actual, subset);
604
+ return this._report(!!passed, `to match object ${inspect(subset)}`, this._actual, subset, message);
605
+ }
606
+ /**
607
+ * Check that the value has a property at the given path.
608
+ * When `value` is provided, also checks property value equality.
609
+ *
610
+ * @param path Dot-separated property path (e.g. `"meta.created"`)
611
+ * @param value Expected value at the path (omit to check existence only)
612
+ * @param message Optional context prepended to the assertion message
613
+ *
614
+ * @example Existence check
615
+ * ```ts
616
+ * ctx.expect(body).toHaveProperty("id");
617
+ * ```
618
+ *
619
+ * @example Value check with message
620
+ * ```ts
621
+ * ctx.expect(body).toHaveProperty("status", "active", "user status");
622
+ * ```
623
+ */
624
+ toHaveProperty(path, value, message) {
625
+ const resolved = resolvePath(this._actual, path);
626
+ let passed = resolved.found;
627
+ if (passed && arguments.length >= 2) {
628
+ passed = deepEqual(resolved.value, value);
629
+ }
630
+ const msg = arguments.length >= 2
631
+ ? `to have property "${path}" with value ${inspect(value)}`
632
+ : `to have property "${path}"`;
633
+ return this._report(passed, msg, arguments.length >= 2 ? resolved.value : resolved.found, arguments.length >= 2 ? value : true, message);
634
+ }
635
+ /**
636
+ * Check that the value has all of the given property keys.
637
+ * Reports all missing keys in a single assertion message.
638
+ *
639
+ * @param keys Array of property paths to check
640
+ * @param message Optional context prepended to the assertion message
641
+ *
642
+ * @example
643
+ * ctx.expect(body).toHaveProperties(["id", "name", "email"], "user fields");
644
+ */
645
+ toHaveProperties(keys, message) {
646
+ const missing = [];
647
+ for (const key of keys) {
648
+ const resolved = resolvePath(this._actual, key);
649
+ if (!resolved.found) {
650
+ missing.push(key);
651
+ }
652
+ }
653
+ const passed = missing.length === 0;
654
+ const msg = passed
655
+ ? `to have properties [${keys.join(", ")}]`
656
+ : `to have properties [${keys.join(", ")}] — missing: [${missing.join(", ")}]`;
657
+ return this._report(passed, msg, missing, [], message);
658
+ }
659
+ /**
660
+ * Custom predicate assertion.
661
+ *
662
+ * @param predicate Function that returns `true` if the assertion passes
663
+ * @param message Optional context prepended to the assertion message
664
+ *
665
+ * @example ctx.expect(body).toSatisfy((b) => b.items.length > 0, "response should have items");
666
+ */
667
+ toSatisfy(predicate, message) {
668
+ let passed;
669
+ try {
670
+ passed = predicate(this._actual);
671
+ }
672
+ catch {
673
+ passed = false;
674
+ }
675
+ const desc = "to satisfy predicate";
676
+ return this._report(passed, desc, this._actual, undefined, message);
677
+ }
678
+ // -------------------------------------------------------------------------
679
+ // HTTP-specific helpers
680
+ // -------------------------------------------------------------------------
681
+ /**
682
+ * Assert on the `status` property (typically a `Response` object).
683
+ *
684
+ * @param code Expected HTTP status code
685
+ * @param message Optional context prepended to the assertion message
686
+ *
687
+ * @example ctx.expect(res).toHaveStatus(200, "GET /users");
688
+ */
689
+ toHaveStatus(code, message) {
690
+ const actual = this._actual?.status;
691
+ return this._report(actual === code, `to have status ${code}`, actual, code, message);
692
+ }
693
+ /**
694
+ * Assert that a Response-like object has a JSON body matching the given subset
695
+ * (partial deep match, like `toMatchObject`).
696
+ *
697
+ * This method is **async** because it calls `.json()` on the response.
698
+ *
699
+ * @param subset Expected subset of properties in the JSON body
700
+ * @param message Optional context prepended to the assertion message
701
+ *
702
+ * @example
703
+ * await ctx.expect(res).toHaveJsonBody({ success: true }, "create order response");
704
+ * (await ctx.expect(res).toHaveJsonBody({ ok: true }, "health check")).orFail();
705
+ */
706
+ async toHaveJsonBody(subset, message) {
707
+ const actual = this._actual;
708
+ if (!actual || typeof actual.json !== "function") {
709
+ return this._report(false, `to have JSON body matching ${inspect(subset)} — actual is not a Response`, this._actual, subset, message);
710
+ }
711
+ let body;
712
+ try {
713
+ body = await actual.json();
714
+ }
715
+ catch {
716
+ return this._report(false, `to have JSON body matching ${inspect(subset)} — failed to parse JSON`, this._actual, subset, message);
717
+ }
718
+ const passed = typeof body === "object" &&
719
+ body !== null &&
720
+ matchesObject(body, subset);
721
+ return this._report(!!passed, `to have JSON body matching ${inspect(subset)}`, body, subset, message);
722
+ }
723
+ /**
724
+ * Assert that a `Response` or headers-like object has a specific header.
725
+ * Optionally check the header value against a string or regex.
726
+ *
727
+ * @param name Header name (case-insensitive for `Headers` objects)
728
+ * @param value Optional expected value or pattern
729
+ * @param message Optional context prepended to the assertion message
730
+ *
731
+ * @example
732
+ * ctx.expect(res).toHaveHeader("content-type", /json/, "response content type");
733
+ * ctx.expect(res).toHaveHeader("x-request-id");
734
+ */
735
+ toHaveHeader(name, value, message) {
736
+ let headerValue;
737
+ const actual = this._actual;
738
+ if (actual?.headers) {
739
+ if (typeof actual.headers.get === "function") {
740
+ headerValue = actual.headers.get(name);
741
+ }
742
+ else {
743
+ headerValue = actual.headers[name] ??
744
+ actual.headers[name.toLowerCase()];
745
+ }
746
+ }
747
+ let passed;
748
+ if (value === undefined) {
749
+ passed = headerValue !== null && headerValue !== undefined;
750
+ }
751
+ else if (value instanceof RegExp) {
752
+ passed = headerValue != null && value.test(headerValue);
753
+ }
754
+ else {
755
+ passed = headerValue === value;
756
+ }
757
+ const expectedDesc = value === undefined
758
+ ? `to have header "${name}"`
759
+ : `to have header "${name}" matching ${inspect(value)}`;
760
+ return this._report(passed, expectedDesc, headerValue, value ?? "(present)", message);
761
+ }
762
+ }
763
+ //# sourceMappingURL=expect.js.map