sounding 0.0.3 → 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.
@@ -1,21 +1,102 @@
1
1
  const assert = require('node:assert/strict')
2
2
 
3
+ const { createSoundingError } = require('./create-error')
4
+
5
+ /** @typedef {import('./types').SoundingExpect} SoundingExpect */
6
+ /** @typedef {import('./types').SoundingExpectation} SoundingExpectation */
7
+
8
+ /**
9
+ * @param {any} target
10
+ * @param {string} path
11
+ * @returns {any}
12
+ */
3
13
  function getPath(target, path) {
4
14
  return path.split('.').reduce((current, segment) => current?.[segment], target)
5
15
  }
6
16
 
17
+ /**
18
+ * @param {any} headers
19
+ * @param {string} name
20
+ * @returns {any}
21
+ */
22
+ function getHeaderValue(headers, name) {
23
+ if (!headers) {
24
+ return undefined
25
+ }
26
+
27
+ if (typeof headers.get === 'function') {
28
+ const value = headers.get(name)
29
+ return value === null ? undefined : value
30
+ }
31
+
32
+ if (headers[name] !== undefined) {
33
+ return headers[name]
34
+ }
35
+
36
+ const normalizedName = name.toLowerCase()
37
+ const matchingEntry = Object.entries(headers).find(
38
+ ([key]) => key.toLowerCase() === normalizedName
39
+ )
40
+
41
+ return matchingEntry?.[1]
42
+ }
43
+
44
+ /**
45
+ * @param {any} actual
46
+ * @param {string} name
47
+ * @returns {any}
48
+ */
7
49
  function getHeader(actual, name) {
8
50
  if (typeof actual?.header === 'function') {
9
51
  return actual.header(name)
10
52
  }
11
53
 
12
- if (typeof actual?.headers?.get === 'function') {
13
- return actual.headers.get(name)
14
- }
54
+ return getHeaderValue(actual?.headers, name)
55
+ }
15
56
 
16
- return actual?.headers?.[name]
57
+ /**
58
+ * @param {any} actual
59
+ * @param {string} name
60
+ * @returns {any}
61
+ */
62
+ function getRequestHeader(actual, name) {
63
+ return getHeaderValue(actual?.request?.headers, name)
17
64
  }
18
65
 
66
+ /**
67
+ * @param {any} actual
68
+ * @returns {boolean}
69
+ */
70
+ function isMailbox(actual) {
71
+ return Boolean(
72
+ actual &&
73
+ typeof actual === 'object' &&
74
+ typeof actual.all === 'function' &&
75
+ typeof actual.latest === 'function'
76
+ )
77
+ }
78
+
79
+ /**
80
+ * @param {any} actual
81
+ * @returns {boolean}
82
+ */
83
+ function isMailMessage(actual) {
84
+ return Boolean(
85
+ actual &&
86
+ typeof actual === 'object' &&
87
+ !Array.isArray(actual) &&
88
+ ('to' in actual ||
89
+ 'subject' in actual ||
90
+ 'template' in actual ||
91
+ 'ctaUrl' in actual ||
92
+ 'status' in actual)
93
+ )
94
+ }
95
+
96
+ /**
97
+ * @param {any} actual
98
+ * @returns {any}
99
+ */
19
100
  function resolveStructuredValue(actual) {
20
101
  if (actual?.data !== undefined) {
21
102
  return actual.data
@@ -24,7 +105,19 @@ function resolveStructuredValue(actual) {
24
105
  return actual
25
106
  }
26
107
 
108
+ /**
109
+ * @param {any} actual
110
+ * @returns {boolean}
111
+ */
27
112
  function shouldUseFallback(actual) {
113
+ if (typeof actual?.receive === 'function' && typeof actual?.events === 'function') {
114
+ return false
115
+ }
116
+
117
+ if (isMailbox(actual) || isMailMessage(actual)) {
118
+ return false
119
+ }
120
+
28
121
  return Boolean(
29
122
  actual &&
30
123
  typeof actual === 'object' &&
@@ -36,6 +129,681 @@ function shouldUseFallback(actual) {
36
129
  )
37
130
  }
38
131
 
132
+ /**
133
+ * @param {any} actual
134
+ * @param {any} expected
135
+ * @returns {boolean}
136
+ */
137
+ function partiallyMatches(actual, expected) {
138
+ if (expected instanceof RegExp) {
139
+ return expected.test(String(actual))
140
+ }
141
+
142
+ if (typeof expected === 'function') {
143
+ return Boolean(expected(actual))
144
+ }
145
+
146
+ if (Array.isArray(expected)) {
147
+ if (!Array.isArray(actual) || actual.length < expected.length) {
148
+ return false
149
+ }
150
+
151
+ return expected.every((entry, index) => partiallyMatches(actual[index], entry))
152
+ }
153
+
154
+ if (expected && typeof expected === 'object') {
155
+ if (!actual || typeof actual !== 'object') {
156
+ return false
157
+ }
158
+
159
+ return Object.entries(expected).every(([key, value]) => partiallyMatches(actual[key], value))
160
+ }
161
+
162
+ return Object.is(actual, expected)
163
+ }
164
+
165
+ /**
166
+ * @param {any} actual
167
+ * @param {any} expected
168
+ * @param {string} message
169
+ */
170
+ function assertPartialMatch(actual, expected, message) {
171
+ if (expected === undefined) {
172
+ assert.notStrictEqual(actual, undefined)
173
+ return
174
+ }
175
+
176
+ assert.ok(partiallyMatches(actual, expected), message)
177
+ }
178
+
179
+ /**
180
+ * @returns {Error}
181
+ */
182
+ function createResponseSessionUnavailableError() {
183
+ return createSoundingError({
184
+ code: 'E_SOUNDING_RESPONSE_SESSION_UNAVAILABLE',
185
+ name: 'SoundingExpectationError',
186
+ message:
187
+ 'Sounding session assertions require a virtual request response. HTTP responses do not expose server-side session state, so use the virtual transport or assert cookies, headers, and follow-up behavior instead.',
188
+ })
189
+ }
190
+
191
+ /**
192
+ * @param {any} actual
193
+ * @returns {any}
194
+ */
195
+ function resolveResponseSession(actual) {
196
+ if (actual?.session && typeof actual.session === 'object' && !Array.isArray(actual.session)) {
197
+ return actual.session
198
+ }
199
+
200
+ throw createResponseSessionUnavailableError()
201
+ }
202
+
203
+ /**
204
+ * @param {any[]} messages
205
+ * @param {any} expected
206
+ * @returns {boolean}
207
+ */
208
+ function flashMessagesMatch(messages, expected) {
209
+ if (expected === undefined) {
210
+ return messages.length > 0
211
+ }
212
+
213
+ if (Array.isArray(expected)) {
214
+ return partiallyMatches(messages, expected)
215
+ }
216
+
217
+ return messages.some((message) => partiallyMatches(message, expected))
218
+ }
219
+
220
+ /**
221
+ * @param {any} value
222
+ * @returns {string}
223
+ */
224
+ function describeExpected(value) {
225
+ if (value instanceof RegExp) {
226
+ return String(value)
227
+ }
228
+
229
+ if (typeof value === 'function') {
230
+ return value.name ? `[Function: ${value.name}]` : '[Function]'
231
+ }
232
+
233
+ return JSON.stringify(value, (_key, nested) => {
234
+ if (nested instanceof RegExp) {
235
+ return String(nested)
236
+ }
237
+
238
+ if (typeof nested === 'function') {
239
+ return nested.name ? `[Function: ${nested.name}]` : '[Function]'
240
+ }
241
+
242
+ return nested
243
+ })
244
+ }
245
+
246
+ /**
247
+ * @param {string} path
248
+ * @param {any} expected
249
+ * @returns {string}
250
+ */
251
+ function formatExpectation(path, expected) {
252
+ if (expected === undefined) {
253
+ return `\`${path}\` to be present`
254
+ }
255
+
256
+ return `\`${path}\` to match ${describeExpected(expected)}`
257
+ }
258
+
259
+ /**
260
+ * @param {any} target
261
+ * @returns {any[]}
262
+ */
263
+ function resolveMailboxMessages(target) {
264
+ if (isMailbox(target)) {
265
+ return target.all()
266
+ }
267
+
268
+ throw new TypeError('Sounding expect().toHaveSentMail() requires a Sounding mailbox.')
269
+ }
270
+
271
+ /**
272
+ * @param {any} target
273
+ * @returns {any}
274
+ */
275
+ function resolveMailMessage(target) {
276
+ if (isMailMessage(target)) {
277
+ return target
278
+ }
279
+
280
+ throw new TypeError('Sounding expect().toHaveCtaUrl() requires a captured mail message.')
281
+ }
282
+
283
+ /**
284
+ * @param {any[]} actual
285
+ * @param {any} expected
286
+ * @returns {boolean}
287
+ */
288
+ function listContainsPartial(actual, expected) {
289
+ if (Array.isArray(expected)) {
290
+ return partiallyMatches(actual, expected)
291
+ }
292
+
293
+ return actual.some((entry) => partiallyMatches(entry, expected))
294
+ }
295
+
296
+ /**
297
+ * @param {any} message
298
+ * @param {any} expected
299
+ * @returns {boolean}
300
+ */
301
+ function mailMatches(message, expected = {}) {
302
+ if (typeof expected === 'function' || expected instanceof RegExp) {
303
+ return partiallyMatches(message, expected)
304
+ }
305
+
306
+ return Object.entries(expected).every(([key, value]) => {
307
+ const actualValue = getPath(message, key)
308
+
309
+ if (Array.isArray(actualValue)) {
310
+ return listContainsPartial(actualValue, value)
311
+ }
312
+
313
+ return partiallyMatches(actualValue, value)
314
+ })
315
+ }
316
+
317
+ /**
318
+ * @param {any} message
319
+ * @returns {any}
320
+ */
321
+ function summarizeMailMessage(message) {
322
+ return {
323
+ to: message?.to,
324
+ subject: message?.subject,
325
+ template: message?.template,
326
+ status: message?.status,
327
+ ctaUrl: message?.ctaUrl,
328
+ }
329
+ }
330
+
331
+ /**
332
+ * @param {any[]} messages
333
+ * @returns {string}
334
+ */
335
+ function summarizeMailMessages(messages) {
336
+ return describeExpected(messages.map(summarizeMailMessage))
337
+ }
338
+
339
+ /**
340
+ * @param {any} headers
341
+ * @returns {Array<[string, string]>}
342
+ */
343
+ function getHeaderEntries(headers) {
344
+ if (!headers) {
345
+ return []
346
+ }
347
+
348
+ if (typeof headers.forEach === 'function') {
349
+ const entries = []
350
+ headers.forEach((value, key) => {
351
+ entries.push([key, value])
352
+ })
353
+ return entries
354
+ }
355
+
356
+ return Object.entries(headers).map(([key, value]) => [key, String(value)])
357
+ }
358
+
359
+ /**
360
+ * @param {any} headers
361
+ * @returns {string}
362
+ */
363
+ function summarizeHeaders(headers) {
364
+ const entries = getHeaderEntries(headers)
365
+ const limit = usesVerboseDiagnostics() ? entries.length : 6
366
+ const visibleEntries = entries.slice(0, limit).map(([key, value]) => `${key}: ${value}`)
367
+
368
+ if (entries.length > visibleEntries.length) {
369
+ visibleEntries.push(`... ${entries.length - visibleEntries.length} more`)
370
+ }
371
+
372
+ return visibleEntries.join(', ')
373
+ }
374
+
375
+ /**
376
+ * @param {string} value
377
+ * @param {number} maxLength
378
+ * @returns {string}
379
+ */
380
+ function truncate(value, maxLength) {
381
+ if (value.length <= maxLength) {
382
+ return value
383
+ }
384
+
385
+ return `${value.slice(0, maxLength)}...`
386
+ }
387
+
388
+ /**
389
+ * @param {any} actual
390
+ * @returns {string}
391
+ */
392
+ function summarizeResponseBody(actual) {
393
+ const body = actual?.body || (actual?.data === undefined ? '' : describeExpected(actual.data))
394
+ const limit = usesVerboseDiagnostics() ? Infinity : 500
395
+ return truncate(String(body).replace(/\s+/g, ' ').trim(), limit)
396
+ }
397
+
398
+ /**
399
+ * @returns {boolean}
400
+ */
401
+ function usesVerboseDiagnostics() {
402
+ return process.env.SOUNDING_DIAGNOSTICS === 'verbose'
403
+ }
404
+
405
+ /**
406
+ * @param {any} actual
407
+ * @returns {string}
408
+ */
409
+ function formatResponseDiagnostics(actual) {
410
+ const request = actual?.request
411
+ const hasResponseContext =
412
+ Boolean(request) || actual?.status !== undefined || actual?.url !== undefined
413
+
414
+ if (!hasResponseContext) {
415
+ return ''
416
+ }
417
+
418
+ const lines = []
419
+
420
+ if (request) {
421
+ const transport = request.transport ? ` (${request.transport})` : ''
422
+ const url = request.url && request.url !== request.target ? ` -> ${request.url}` : ''
423
+ lines.push(`Request: ${request.method} ${request.target}${transport}${url}`)
424
+ const requestHeaders = summarizeHeaders(request.headers)
425
+ if (requestHeaders) {
426
+ lines.push(`Request headers: ${requestHeaders}`)
427
+ }
428
+ } else if (actual?.url) {
429
+ lines.push(`URL: ${actual.url}`)
430
+ }
431
+
432
+ if (actual?.status !== undefined) {
433
+ const statusText = actual.statusText ? ` ${actual.statusText}` : ''
434
+ lines.push(`Response: ${actual.status}${statusText}`)
435
+ }
436
+
437
+ const headers = summarizeHeaders(actual?.headers)
438
+ if (headers) {
439
+ lines.push(`Headers: ${headers}`)
440
+ }
441
+
442
+ const body = summarizeResponseBody(actual)
443
+ if (body) {
444
+ lines.push(`Body: ${body}`)
445
+ }
446
+
447
+ if (lines.length === 0) {
448
+ return ''
449
+ }
450
+
451
+ return `\n\nSounding response diagnostics:\n${lines.map((line) => `- ${line}`).join('\n')}`
452
+ }
453
+
454
+ /**
455
+ * @param {string} message
456
+ * @param {any} actual
457
+ */
458
+ function failWithResponseDiagnostics(message, actual) {
459
+ assert.fail(`${message}${formatResponseDiagnostics(actual)}`)
460
+ }
461
+
462
+ /**
463
+ * @param {any} value
464
+ * @param {any} expected
465
+ * @param {string} message
466
+ * @param {any} actual
467
+ */
468
+ function assertDeepEqualWithResponseDiagnostics(value, expected, message, actual) {
469
+ try {
470
+ assert.deepStrictEqual(value, expected)
471
+ } catch (_error) {
472
+ failWithResponseDiagnostics(message, actual)
473
+ }
474
+ }
475
+
476
+ /**
477
+ * @param {any} actual
478
+ * @returns {any}
479
+ */
480
+ function resolveInertiaPage(actual) {
481
+ return resolveStructuredValue(actual) || {}
482
+ }
483
+
484
+ /**
485
+ * @param {any} actual
486
+ * @returns {any}
487
+ */
488
+ function resolveInertiaProps(actual) {
489
+ return resolveInertiaPage(actual)?.props || {}
490
+ }
491
+
492
+ /**
493
+ * @param {any} actual
494
+ * @returns {any}
495
+ */
496
+ function resolveSharedInertiaProps(actual) {
497
+ const page = resolveInertiaPage(actual)
498
+ return page?.sharedProps || page?.shared || page?.props?.shared || page?.props || {}
499
+ }
500
+
501
+ /**
502
+ * @param {any} actual
503
+ * @returns {any}
504
+ */
505
+ function resolveInertiaErrors(actual) {
506
+ return resolveInertiaProps(actual)?.errors || {}
507
+ }
508
+
509
+ /**
510
+ * @param {any} value
511
+ * @returns {boolean}
512
+ */
513
+ function hasEntries(value) {
514
+ if (Array.isArray(value)) {
515
+ return value.length > 0
516
+ }
517
+
518
+ if (value && typeof value === 'object') {
519
+ return Object.keys(value).length > 0
520
+ }
521
+
522
+ return Boolean(value)
523
+ }
524
+
525
+ /**
526
+ * @param {any} source
527
+ * @param {string} path
528
+ * @param {any} expected
529
+ * @param {string} label
530
+ * @param {any} actual
531
+ */
532
+ function assertInertiaPath(source, path, expected, label, actual) {
533
+ const value = getPath(source, path)
534
+
535
+ if (expected === undefined) {
536
+ if (value === undefined) {
537
+ failWithResponseDiagnostics(`Expected ${label} \`${path}\` to be present.`, actual)
538
+ }
539
+ return
540
+ }
541
+
542
+ if (!partiallyMatches(value, expected)) {
543
+ failWithResponseDiagnostics(
544
+ `Expected ${label} ${formatExpectation(path, expected)}, received ${describeExpected(value)}.`,
545
+ actual
546
+ )
547
+ }
548
+ }
549
+
550
+ /**
551
+ * @param {any} source
552
+ * @param {string} path
553
+ * @param {any} expected
554
+ * @param {string} label
555
+ * @param {any} actual
556
+ */
557
+ function assertInertiaPathAbsent(source, path, expected, label, actual) {
558
+ const value = getPath(source, path)
559
+
560
+ if (expected === undefined) {
561
+ if (value !== undefined) {
562
+ failWithResponseDiagnostics(
563
+ `Expected ${label} \`${path}\` to be absent, received ${describeExpected(value)}.`,
564
+ actual
565
+ )
566
+ }
567
+ return
568
+ }
569
+
570
+ if (partiallyMatches(value, expected)) {
571
+ failWithResponseDiagnostics(
572
+ `Expected ${label} \`${path}\` not to match ${describeExpected(expected)}.`,
573
+ actual
574
+ )
575
+ }
576
+ }
577
+
578
+ /**
579
+ * @param {any} source
580
+ * @param {Record<string, any>} expected
581
+ * @param {string} matcherName
582
+ * @param {string} pathLabel
583
+ * @param {any} actual
584
+ */
585
+ function assertInertiaPathMap(source, expected, matcherName, pathLabel, actual) {
586
+ if (!expected || typeof expected !== 'object' || Array.isArray(expected)) {
587
+ throw new TypeError(`Sounding expect().${matcherName}() requires an object of prop paths.`)
588
+ }
589
+
590
+ for (const [path, value] of Object.entries(expected)) {
591
+ assertInertiaPath(source, path, value, pathLabel, actual)
592
+ }
593
+ }
594
+
595
+ /**
596
+ * @param {any} value
597
+ * @returns {number | null}
598
+ */
599
+ function countCollection(value) {
600
+ if (Array.isArray(value)) {
601
+ return value.length
602
+ }
603
+
604
+ if (value && typeof value === 'object') {
605
+ return Object.keys(value).length
606
+ }
607
+
608
+ return null
609
+ }
610
+
611
+ /**
612
+ * @param {any} actual
613
+ * @param {string} path
614
+ * @param {number} expected
615
+ */
616
+ function assertInertiaPropCount(actual, path, expected) {
617
+ const value = getPath(resolveInertiaProps(actual), path)
618
+ const count = countCollection(value)
619
+
620
+ if (count === null) {
621
+ failWithResponseDiagnostics(
622
+ `Expected Inertia prop \`${path}\` to be an array or object with ${expected} item(s), received ${describeExpected(value)}.`,
623
+ actual
624
+ )
625
+ }
626
+
627
+ if (count !== expected) {
628
+ failWithResponseDiagnostics(
629
+ `Expected Inertia prop \`${path}\` to have ${expected} item(s), received ${count}.`,
630
+ actual
631
+ )
632
+ }
633
+ }
634
+
635
+ /**
636
+ * @param {any} actual
637
+ * @param {string[]} expected
638
+ */
639
+ function assertOnlyInertiaProps(actual, expected) {
640
+ if (!Array.isArray(expected)) {
641
+ throw new TypeError('Sounding expect().toHaveOnlyInertiaProps() requires an array of top-level prop names.')
642
+ }
643
+
644
+ const actualKeys = Object.keys(resolveInertiaProps(actual)).sort()
645
+ const expectedKeys = [...expected].sort()
646
+
647
+ assertDeepEqualWithResponseDiagnostics(
648
+ actualKeys,
649
+ expectedKeys,
650
+ `Expected Inertia props to include only ${describeExpected(expectedKeys)}, received ${describeExpected(actualKeys)}.`,
651
+ actual
652
+ )
653
+ }
654
+
655
+ /**
656
+ * @param {string | string[]} value
657
+ * @returns {string[]}
658
+ */
659
+ function normalizeHeaderList(value) {
660
+ if (Array.isArray(value)) {
661
+ return value.map(String)
662
+ }
663
+
664
+ return String(value || '')
665
+ .split(',')
666
+ .map((entry) => entry.trim())
667
+ .filter(Boolean)
668
+ }
669
+
670
+ /**
671
+ * @param {any} actual
672
+ * @param {string} name
673
+ * @param {string[]} expected
674
+ * @param {string} label
675
+ */
676
+ function assertPartialReloadList(actual, name, expected, label) {
677
+ if (!Array.isArray(expected)) {
678
+ throw new TypeError(`Sounding expect().toHaveInertiaPartialReload() requires \`${label}\` to be an array.`)
679
+ }
680
+
681
+ const value = getRequestHeader(actual, name)
682
+ const actualList = normalizeHeaderList(value)
683
+
684
+ assertDeepEqualWithResponseDiagnostics(
685
+ actualList,
686
+ expected,
687
+ `Expected Inertia partial reload \`${label}\` to equal ${describeExpected(expected)}, received ${describeExpected(actualList)}.`,
688
+ actual
689
+ )
690
+ }
691
+
692
+ /**
693
+ * @param {any} actual
694
+ * @param {{ component?: string, only?: string[], except?: string[], reset?: string[], version?: string, errorBag?: string }} [expected]
695
+ */
696
+ function assertInertiaPartialReload(actual, expected = {}) {
697
+ const headerNames = [
698
+ 'x-inertia-partial-component',
699
+ 'x-inertia-partial-data',
700
+ 'x-inertia-partial-except',
701
+ 'x-inertia-reset',
702
+ ]
703
+ const hasPartialReloadHeader = headerNames.some((name) => getRequestHeader(actual, name) !== undefined)
704
+
705
+ if (!hasPartialReloadHeader) {
706
+ failWithResponseDiagnostics('Expected request to include Inertia partial reload headers.', actual)
707
+ }
708
+
709
+ if (expected.component !== undefined) {
710
+ const component = getRequestHeader(actual, 'x-inertia-partial-component')
711
+ if (component !== expected.component) {
712
+ failWithResponseDiagnostics(
713
+ `Expected Inertia partial reload component ${describeExpected(expected.component)}, received ${describeExpected(component)}.`,
714
+ actual
715
+ )
716
+ }
717
+ }
718
+
719
+ if (expected.only !== undefined) {
720
+ assertPartialReloadList(actual, 'x-inertia-partial-data', expected.only, 'only')
721
+ }
722
+
723
+ if (expected.except !== undefined) {
724
+ assertPartialReloadList(actual, 'x-inertia-partial-except', expected.except, 'except')
725
+ }
726
+
727
+ if (expected.reset !== undefined) {
728
+ assertPartialReloadList(actual, 'x-inertia-reset', expected.reset, 'reset')
729
+ }
730
+
731
+ if (expected.version !== undefined) {
732
+ const version = getRequestHeader(actual, 'x-inertia-version')
733
+ if (version !== expected.version) {
734
+ failWithResponseDiagnostics(
735
+ `Expected Inertia version ${describeExpected(expected.version)}, received ${describeExpected(version)}.`,
736
+ actual
737
+ )
738
+ }
739
+ }
740
+
741
+ if (expected.errorBag !== undefined) {
742
+ const errorBag = getRequestHeader(actual, 'x-inertia-error-bag')
743
+ if (errorBag !== expected.errorBag) {
744
+ failWithResponseDiagnostics(
745
+ `Expected Inertia error bag ${describeExpected(expected.errorBag)}, received ${describeExpected(errorBag)}.`,
746
+ actual
747
+ )
748
+ }
749
+ }
750
+ }
751
+
752
+ /**
753
+ * @param {any} actual
754
+ * @param {string | string[] | Record<string, any>} [expected]
755
+ */
756
+ function assertInertiaErrors(actual, expected) {
757
+ const errors = resolveInertiaErrors(actual)
758
+
759
+ if (expected === undefined) {
760
+ if (!hasEntries(errors)) {
761
+ failWithResponseDiagnostics('Expected Inertia validation errors to be present.', actual)
762
+ }
763
+ return
764
+ }
765
+
766
+ if (typeof expected === 'string') {
767
+ assertInertiaPath(errors, expected, undefined, 'Inertia validation error', actual)
768
+ return
769
+ }
770
+
771
+ if (Array.isArray(expected)) {
772
+ for (const path of expected) {
773
+ assertInertiaPath(errors, path, undefined, 'Inertia validation error', actual)
774
+ }
775
+ return
776
+ }
777
+
778
+ if (expected && typeof expected === 'object') {
779
+ for (const [path, value] of Object.entries(expected)) {
780
+ assertInertiaPath(errors, path, value, 'Inertia validation error', actual)
781
+ }
782
+ return
783
+ }
784
+
785
+ throw new TypeError('Sounding expect().toHaveInertiaErrors() requires a string, array, object, or no argument.')
786
+ }
787
+
788
+ /**
789
+ * @param {any} actual
790
+ */
791
+ function assertNoInertiaErrors(actual) {
792
+ const errors = resolveInertiaErrors(actual)
793
+
794
+ if (hasEntries(errors)) {
795
+ failWithResponseDiagnostics(
796
+ `Expected Inertia validation errors to be empty, received ${describeExpected(errors)}.`,
797
+ actual
798
+ )
799
+ }
800
+ }
801
+
802
+ /**
803
+ * @param {any} actual
804
+ * @param {{ fallback?: (actual: any) => any }} [options]
805
+ * @returns {SoundingExpectation | any}
806
+ */
39
807
  function createExpect(actual, { fallback } = {}) {
40
808
  if (fallback && shouldUseFallback(actual)) {
41
809
  return fallback(actual)
@@ -86,70 +854,345 @@ function createExpect(actual, { fallback } = {}) {
86
854
  },
87
855
 
88
856
  toHaveStatus(expected) {
89
- assert.strictEqual(actual?.status, expected)
857
+ if (actual?.status !== expected) {
858
+ failWithResponseDiagnostics(
859
+ `Expected response status ${expected}, received ${describeExpected(actual?.status)}.`,
860
+ actual
861
+ )
862
+ }
90
863
  },
91
864
 
92
865
  toHaveHeader(name, expected) {
93
866
  const header = getHeader(actual, name)
94
- assert.notStrictEqual(header, null)
95
- assert.notStrictEqual(header, undefined)
867
+ if (header === null || header === undefined) {
868
+ failWithResponseDiagnostics(`Expected response header \`${name}\` to be present.`, actual)
869
+ }
96
870
 
97
- if (expected !== undefined) {
98
- assert.strictEqual(header, expected)
871
+ if (expected !== undefined && header !== expected) {
872
+ failWithResponseDiagnostics(
873
+ `Expected response header \`${name}\` to equal ${describeExpected(expected)}, received ${describeExpected(header)}.`,
874
+ actual
875
+ )
99
876
  }
100
877
  },
101
878
 
102
879
  toRedirectTo(expected) {
103
880
  const location = getHeader(actual, 'location')
104
- assert.strictEqual(location, expected)
881
+ if (location !== expected) {
882
+ failWithResponseDiagnostics(
883
+ `Expected response to redirect to ${describeExpected(expected)}, received ${describeExpected(location)}.`,
884
+ actual
885
+ )
886
+ }
105
887
  },
106
888
 
107
889
  toHaveJsonPath(path, expected) {
108
890
  const value = getPath(resolveStructuredValue(actual), path)
109
- assert.deepStrictEqual(value, expected)
891
+ assertDeepEqualWithResponseDiagnostics(
892
+ value,
893
+ expected,
894
+ `Expected JSON path ${formatExpectation(path, expected)}, received ${describeExpected(value)}.`,
895
+ actual
896
+ )
897
+ },
898
+
899
+ toHaveSentCount(expected) {
900
+ const messages = resolveMailboxMessages(actual)
901
+ assert.strictEqual(
902
+ messages.length,
903
+ expected,
904
+ `Expected mailbox to have sent ${expected} message(s), received ${messages.length}. Captured mail: ${summarizeMailMessages(messages)}.`
905
+ )
906
+ },
907
+
908
+ toHaveSentMail(expected = {}) {
909
+ const messages = resolveMailboxMessages(actual)
910
+
911
+ assert.ok(
912
+ messages.some((message) => mailMatches(message, expected)),
913
+ `Expected mailbox to have sent mail matching ${describeExpected(expected)}. Captured mail: ${summarizeMailMessages(messages)}.`
914
+ )
915
+ },
916
+
917
+ toHaveCtaUrl(expected) {
918
+ const message = resolveMailMessage(actual)
919
+ const ctaUrl = message.ctaUrl
920
+
921
+ if (expected === undefined) {
922
+ assert.notStrictEqual(ctaUrl, undefined, 'Expected captured mail to have a CTA URL.')
923
+ return
924
+ }
925
+
926
+ assertPartialMatch(
927
+ ctaUrl,
928
+ expected,
929
+ `Expected captured mail CTA URL to match ${describeExpected(expected)}, received ${describeExpected(ctaUrl)}.`
930
+ )
931
+ },
932
+
933
+ toHaveSession(path, expected) {
934
+ const session = resolveResponseSession(actual)
935
+ const value = getPath(session, path)
936
+
937
+ if (expected === undefined) {
938
+ assert.notStrictEqual(value, undefined, `Expected session ${formatExpectation(path)}.`)
939
+ return
940
+ }
941
+
942
+ assertPartialMatch(
943
+ value,
944
+ expected,
945
+ `Expected session ${formatExpectation(path, expected)}, received ${describeExpected(value)}.`
946
+ )
947
+ },
948
+
949
+ toHaveFlash(type, expected) {
950
+ const session = resolveResponseSession(actual)
951
+ const messages = session.__soundingFlashStore?.[type] || []
952
+
953
+ assert.ok(
954
+ flashMessagesMatch(messages, expected),
955
+ expected === undefined
956
+ ? `Expected flash \`${type}\` to be present.`
957
+ : `Expected flash \`${type}\` to match ${describeExpected(expected)}, received ${describeExpected(messages)}.`
958
+ )
110
959
  },
111
960
 
112
961
  toBeInertiaPage(component) {
113
962
  const value = resolveStructuredValue(actual)
114
- assert.strictEqual(value?.component, component)
963
+ if (value?.component !== component) {
964
+ failWithResponseDiagnostics(
965
+ `Expected Inertia component ${describeExpected(component)}, received ${describeExpected(value?.component)}.`,
966
+ actual
967
+ )
968
+ }
969
+ },
970
+
971
+ toHaveInertiaProp(path, expected) {
972
+ assertInertiaPath(resolveInertiaProps(actual), path, expected, 'Inertia prop', actual)
973
+ },
974
+
975
+ toHaveInertiaProps(expected) {
976
+ assertInertiaPathMap(
977
+ resolveInertiaProps(actual),
978
+ expected,
979
+ 'toHaveInertiaProps',
980
+ 'Inertia prop',
981
+ actual
982
+ )
983
+ },
984
+
985
+ toHaveInertiaPropCount(path, expected) {
986
+ assertInertiaPropCount(actual, path, expected)
115
987
  },
116
988
 
117
- toHaveProp(path, expected) {
118
- const value = getPath(resolveStructuredValue(actual)?.props, path)
119
- assert.deepStrictEqual(value, expected)
989
+ toHaveOnlyInertiaProps(expected) {
990
+ assertOnlyInertiaProps(actual, expected)
120
991
  },
121
992
 
122
- toMatchProp(path, expected) {
123
- const value = getPath(resolveStructuredValue(actual)?.props, path)
993
+ toMatchInertiaProp(path, expected) {
994
+ const value = getPath(resolveInertiaProps(actual), path)
124
995
 
125
996
  if (expected instanceof RegExp) {
126
- assert.match(String(value), expected)
997
+ if (!expected.test(String(value))) {
998
+ failWithResponseDiagnostics(
999
+ `Expected Inertia prop ${formatExpectation(path, expected)}, received ${describeExpected(value)}.`,
1000
+ actual
1001
+ )
1002
+ }
127
1003
  return
128
1004
  }
129
1005
 
130
- assert.ok(String(value).includes(String(expected)))
1006
+ if (!String(value).includes(String(expected))) {
1007
+ failWithResponseDiagnostics(
1008
+ `Expected Inertia prop \`${path}\` to include ${describeExpected(expected)}, received ${describeExpected(value)}.`,
1009
+ actual
1010
+ )
1011
+ }
1012
+ },
1013
+
1014
+ toHaveSharedInertiaProp(path, expected) {
1015
+ assertInertiaPath(
1016
+ resolveSharedInertiaProps(actual),
1017
+ path,
1018
+ expected,
1019
+ 'shared Inertia prop',
1020
+ actual
1021
+ )
1022
+ },
1023
+
1024
+ toHaveSharedInertiaProps(expected) {
1025
+ assertInertiaPathMap(
1026
+ resolveSharedInertiaProps(actual),
1027
+ expected,
1028
+ 'toHaveSharedInertiaProps',
1029
+ 'shared Inertia prop',
1030
+ actual
1031
+ )
1032
+ },
1033
+
1034
+ toHaveInertiaError(path, expected) {
1035
+ assertInertiaPath(
1036
+ resolveInertiaErrors(actual),
1037
+ path,
1038
+ expected,
1039
+ 'Inertia validation error',
1040
+ actual
1041
+ )
1042
+ },
1043
+
1044
+ toHaveInertiaErrors(expected) {
1045
+ assertInertiaErrors(actual, expected)
131
1046
  },
132
1047
 
133
- toHaveSharedProp(path, expected) {
134
- const value = getPath(resolveStructuredValue(actual)?.props, path)
135
- assert.deepStrictEqual(value, expected)
1048
+ toHaveNoInertiaErrors() {
1049
+ assertNoInertiaErrors(actual)
136
1050
  },
137
1051
 
138
- toHaveValidationError(path, expected) {
139
- const value = getPath(resolveStructuredValue(actual)?.props?.errors, path)
140
- assert.notStrictEqual(value, undefined)
1052
+ toHaveInertiaPartialReload(expected) {
1053
+ assertInertiaPartialReload(actual, expected)
1054
+ },
1055
+
1056
+ async toReceive(event, expected, options) {
1057
+ if (typeof actual?.receive !== 'function') {
1058
+ throw new TypeError('Sounding expect().toReceive() requires a Sounding socket client.')
1059
+ }
1060
+
1061
+ const payload = await actual.receive(event, options)
1062
+ assertPartialMatch(
1063
+ payload,
1064
+ expected,
1065
+ `Expected socket event \`${event}\` to match ${JSON.stringify(expected)}, received ${JSON.stringify(payload)}.`
1066
+ )
1067
+ },
1068
+
1069
+ toHaveReceived(event, expected) {
1070
+ if (typeof actual?.events !== 'function') {
1071
+ throw new TypeError('Sounding expect().toHaveReceived() requires a Sounding socket client.')
1072
+ }
1073
+
1074
+ const payloads = actual.events(event)
1075
+ assert.ok(payloads.length > 0, `Expected socket to have received \`${event}\`.`)
141
1076
 
142
1077
  if (expected !== undefined) {
143
- assert.deepStrictEqual(value, expected)
1078
+ assert.ok(
1079
+ payloads.some((payload) => partiallyMatches(payload, expected)),
1080
+ `Expected received socket event \`${event}\` to match ${JSON.stringify(expected)}.`
1081
+ )
144
1082
  }
145
1083
  },
1084
+
1085
+ not: {
1086
+ toHaveSentMail(expected = {}) {
1087
+ const messages = resolveMailboxMessages(actual)
1088
+
1089
+ assert.ok(
1090
+ !messages.some((message) => mailMatches(message, expected)),
1091
+ `Expected mailbox not to have sent mail matching ${describeExpected(expected)}. Captured mail: ${summarizeMailMessages(messages)}.`
1092
+ )
1093
+ },
1094
+
1095
+ toHaveCtaUrl(expected) {
1096
+ const message = resolveMailMessage(actual)
1097
+ const ctaUrl = message.ctaUrl
1098
+
1099
+ if (expected === undefined) {
1100
+ assert.strictEqual(ctaUrl, undefined, 'Expected captured mail not to have a CTA URL.')
1101
+ return
1102
+ }
1103
+
1104
+ assert.ok(
1105
+ !partiallyMatches(ctaUrl, expected),
1106
+ `Expected captured mail CTA URL not to match ${describeExpected(expected)}.`
1107
+ )
1108
+ },
1109
+
1110
+ toHaveSession(path, expected) {
1111
+ const session = resolveResponseSession(actual)
1112
+ const value = getPath(session, path)
1113
+
1114
+ if (expected === undefined) {
1115
+ assert.strictEqual(value, undefined, `Expected session not to include \`${path}\`.`)
1116
+ return
1117
+ }
1118
+
1119
+ assert.ok(
1120
+ !partiallyMatches(value, expected),
1121
+ `Expected session \`${path}\` not to match ${describeExpected(expected)}.`
1122
+ )
1123
+ },
1124
+
1125
+ toHaveFlash(type, expected) {
1126
+ const session = resolveResponseSession(actual)
1127
+ const messages = session.__soundingFlashStore?.[type] || []
1128
+
1129
+ assert.ok(
1130
+ !flashMessagesMatch(messages, expected),
1131
+ expected === undefined
1132
+ ? `Expected flash \`${type}\` not to be present.`
1133
+ : `Expected flash \`${type}\` not to match ${describeExpected(expected)}.`
1134
+ )
1135
+ },
1136
+
1137
+ toHaveInertiaProp(path, expected) {
1138
+ assertInertiaPathAbsent(resolveInertiaProps(actual), path, expected, 'Inertia prop', actual)
1139
+ },
1140
+
1141
+ toHaveSharedInertiaProp(path, expected) {
1142
+ assertInertiaPathAbsent(
1143
+ resolveSharedInertiaProps(actual),
1144
+ path,
1145
+ expected,
1146
+ 'shared Inertia prop',
1147
+ actual
1148
+ )
1149
+ },
1150
+
1151
+ toHaveInertiaError(path, expected) {
1152
+ assertInertiaPathAbsent(
1153
+ resolveInertiaErrors(actual),
1154
+ path,
1155
+ expected,
1156
+ 'Inertia validation error',
1157
+ actual
1158
+ )
1159
+ },
1160
+
1161
+ async toReceive(event, expected, options = {}) {
1162
+ if (typeof actual?.receive !== 'function') {
1163
+ throw new TypeError('Sounding expect().not.toReceive() requires a Sounding socket client.')
1164
+ }
1165
+
1166
+ const timeout = options.timeout || 50
1167
+
1168
+ try {
1169
+ const payload = await actual.receive(event, { ...options, timeout })
1170
+ if (expected === undefined || partiallyMatches(payload, expected)) {
1171
+ assert.fail(`Expected socket not to receive \`${event}\`, but it did.`)
1172
+ }
1173
+ } catch (error) {
1174
+ if (error?.code === 'E_SOUNDING_SOCKET_EVENT_TIMEOUT') {
1175
+ return
1176
+ }
1177
+
1178
+ throw error
1179
+ }
1180
+ },
1181
+ },
146
1182
  }
147
1183
  }
148
1184
 
1185
+ /**
1186
+ * @param {(actual: any) => any} fallback
1187
+ * @returns {SoundingExpect}
1188
+ */
149
1189
  createExpect.withFallback = function withFallback(fallback) {
150
- return function soundingExpect(actual) {
1190
+ function soundingExpect(actual) {
151
1191
  return createExpect(actual, { fallback })
152
1192
  }
1193
+
1194
+ soundingExpect.withFallback = createExpect.withFallback
1195
+ return soundingExpect
153
1196
  }
154
1197
 
155
1198
  module.exports = { createExpect }