@tishlang/tish-lsp 3.6.0 → 3.7.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 (36) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-lsp +0 -0
  3. package/crates/tish/Cargo.toml +4 -1
  4. package/crates/tish/src/cli_help.rs +84 -0
  5. package/crates/tish/src/main.rs +93 -1
  6. package/crates/tish/tests/test_runner.rs +673 -0
  7. package/crates/tish_bytecode/src/compiler.rs +25 -4
  8. package/crates/tish_compile/src/resolve.rs +45 -12
  9. package/crates/tish_core/src/lib.rs +103 -11
  10. package/crates/tish_eval/src/eval.rs +16 -0
  11. package/crates/tish_eval/src/value_convert.rs +10 -0
  12. package/crates/tish_lsp/Cargo.toml +1 -1
  13. package/crates/tish_test/Cargo.toml +47 -0
  14. package/crates/tish_test/src/assert.rs +435 -0
  15. package/crates/tish_test/src/config.rs +85 -0
  16. package/crates/tish_test/src/coverage.rs +237 -0
  17. package/crates/tish_test/src/deep_equal.rs +261 -0
  18. package/crates/tish_test/src/discovery.rs +70 -0
  19. package/crates/tish_test/src/expect.rs +977 -0
  20. package/crates/tish_test/src/instrument.rs +457 -0
  21. package/crates/tish_test/src/isolation.rs +14 -0
  22. package/crates/tish_test/src/lib.rs +22 -0
  23. package/crates/tish_test/src/load.rs +152 -0
  24. package/crates/tish_test/src/mocks.rs +270 -0
  25. package/crates/tish_test/src/module.rs +318 -0
  26. package/crates/tish_test/src/registry.rs +235 -0
  27. package/crates/tish_test/src/report.rs +312 -0
  28. package/crates/tish_test/src/runner.rs +797 -0
  29. package/crates/tish_test/src/snapshots.rs +197 -0
  30. package/crates/tish_vm/src/vm.rs +17 -14
  31. package/package.json +1 -1
  32. package/platform/darwin-arm64/tish-lsp +0 -0
  33. package/platform/darwin-x64/tish-lsp +0 -0
  34. package/platform/linux-arm64/tish-lsp +0 -0
  35. package/platform/linux-x64/tish-lsp +0 -0
  36. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -0,0 +1,977 @@
1
+ //! Jest/Bun-shaped `expect` matchers.
2
+
3
+ use tishlang_core::{
4
+ has_pending_throw, set_pending_throw, take_pending_throw, value_call, Arc, ObjectMap, Value,
5
+ };
6
+
7
+ use crate::assert::assertion_error;
8
+ use crate::deep_equal::deep_strict_equal;
9
+
10
+ fn fail(msg: String, actual: Value, expected: Option<Value>, op: &str) -> Value {
11
+ set_pending_throw(assertion_error(msg, Some(actual), expected, op, true));
12
+ Value::Null
13
+ }
14
+
15
+ fn is_truthy(v: &Value) -> bool {
16
+ match v {
17
+ Value::Null => false,
18
+ Value::Bool(b) => *b,
19
+ Value::Number(n) => *n != 0.0 && !n.is_nan(),
20
+ Value::String(s) => !s.is_empty(),
21
+ _ => true,
22
+ }
23
+ }
24
+
25
+ /// Can `value_call` actually invoke this? Objects are callable only via a `__call` member.
26
+ pub(crate) fn is_callable(v: &Value) -> bool {
27
+ match v {
28
+ Value::Function(_) => true,
29
+ Value::Object(o) => o.borrow().strings.contains_key("__call"),
30
+ _ => false,
31
+ }
32
+ }
33
+
34
+ /// Human-readable text for a thrown value: `Error`-shaped objects report their `message`.
35
+ fn error_text(err: &Value) -> String {
36
+ if let Value::Object(o) = err {
37
+ if let Some(Value::String(m)) = o.borrow().strings.get("message") {
38
+ return m.to_string();
39
+ }
40
+ }
41
+ err.to_display_string()
42
+ }
43
+
44
+ /// Does a thrown value satisfy `expected` (string substring, regex, `Error`-shaped object,
45
+ /// or a deep-equal value)? Node/Jest semantics — the previous version accepted *any* throw.
46
+ pub(crate) fn error_matches(err: &Value, expected: &Value) -> bool {
47
+ match expected {
48
+ Value::String(pat) => error_text(err).contains(pat.as_str()),
49
+ #[cfg(feature = "regex")]
50
+ Value::RegExp(re) => re.borrow_mut().test(&error_text(err)),
51
+ // `{ message, name, … }` — every listed field must match.
52
+ Value::Object(o) if !o.borrow().strings.contains_key(ASYMMETRIC_KEY) => {
53
+ let want = o.borrow();
54
+ if want.strings.is_empty() {
55
+ return deep_strict_equal(err, expected);
56
+ }
57
+ let Value::Object(got) = err else {
58
+ return false;
59
+ };
60
+ let got = got.borrow();
61
+ want.strings.iter().all(|(k, wv)| match got.strings.get(k) {
62
+ Some(gv) => deep_strict_equal(gv, wv),
63
+ None => false,
64
+ })
65
+ }
66
+ _ => deep_strict_equal(err, expected),
67
+ }
68
+ }
69
+
70
+ fn describe_error_matcher(expected: &Value) -> String {
71
+ match expected {
72
+ Value::String(s) => format!("a message containing {:?}", s.as_str()),
73
+ _ => expected.to_display_string(),
74
+ }
75
+ }
76
+
77
+ fn type_of_name(v: &Value) -> &'static str {
78
+ match v {
79
+ Value::Null => "null",
80
+ Value::Bool(_) => "boolean",
81
+ Value::Number(_) => "number",
82
+ Value::String(_) => "string",
83
+ Value::Symbol(_) => "symbol",
84
+ Value::Function(_) => "function",
85
+ _ => "object",
86
+ }
87
+ }
88
+
89
+ /// Resolve a `toHaveProperty` path (`"a.b.0"` or `["a", "b", 0]`) to its value.
90
+ fn property_at(root: &Value, path: &Value) -> Option<Value> {
91
+ let segments: Vec<String> = match path {
92
+ Value::Array(a) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
93
+ other => other
94
+ .to_display_string()
95
+ .split('.')
96
+ .map(|s| s.to_string())
97
+ .collect(),
98
+ };
99
+ let mut cur = root.clone();
100
+ for seg in segments {
101
+ cur = match &cur {
102
+ Value::Object(o) => o.borrow().strings.get(seg.as_str())?.clone(),
103
+ Value::Array(a) => {
104
+ let idx: usize = seg.parse().ok()?;
105
+ a.borrow().get(idx)?.clone()
106
+ }
107
+ Value::NumberArray(a) => {
108
+ let idx: usize = seg.parse().ok()?;
109
+ a.borrow().to_values().get(idx)?.clone()
110
+ }
111
+ _ => return None,
112
+ };
113
+ }
114
+ Some(cur)
115
+ }
116
+
117
+ fn length_of(v: &Value) -> Option<f64> {
118
+ match v {
119
+ Value::Array(a) => Some(a.borrow().len() as f64),
120
+ Value::NumberArray(a) => Some(a.borrow().len() as f64),
121
+ Value::String(s) => Some(s.chars().count() as f64),
122
+ Value::Object(o) => o.borrow().strings.get("length").and_then(|x| x.as_number()),
123
+ _ => None,
124
+ }
125
+ }
126
+
127
+ /// Resolve Jest/Bun mock metadata from `expect(fn)` / `expect(fn.mock)`.
128
+ fn mock_meta(v: &Value) -> Option<Value> {
129
+ let Value::Object(o) = v else {
130
+ return None;
131
+ };
132
+ let b = o.borrow();
133
+ if b.strings.contains_key("calls") && b.strings.contains_key("callCount") {
134
+ return Some(v.clone());
135
+ }
136
+ b.strings.get("mock").cloned().and_then(|m| {
137
+ if let Value::Object(mo) = &m {
138
+ let mb = mo.borrow();
139
+ if mb.strings.contains_key("calls") {
140
+ return Some(m.clone());
141
+ }
142
+ }
143
+ None
144
+ })
145
+ }
146
+
147
+ fn mock_call_count(meta: &Value) -> f64 {
148
+ let Value::Object(o) = meta else {
149
+ return 0.0;
150
+ };
151
+ o.borrow()
152
+ .strings
153
+ .get("callCount")
154
+ .and_then(|v| v.as_number())
155
+ .unwrap_or(0.0)
156
+ }
157
+
158
+ fn mock_calls(meta: &Value) -> Vec<Value> {
159
+ let Value::Object(o) = meta else {
160
+ return Vec::new();
161
+ };
162
+ match o.borrow().strings.get("calls") {
163
+ Some(Value::Array(a)) => a.borrow().clone(),
164
+ _ => Vec::new(),
165
+ }
166
+ }
167
+
168
+ // `!(a > b)` and friends are deliberate: they are TRUE when either side is NaN, which is what
169
+ // these matchers must report. `partial_cmp` would silently pass an incomparable pair.
170
+ #[allow(clippy::neg_cmp_op_on_partial_ord)]
171
+ fn matcher_object(actual: Value) -> Value {
172
+ let actual_be = actual.clone();
173
+ let actual_eq = actual.clone();
174
+ let actual_strict = actual.clone();
175
+ let actual_throw = actual.clone();
176
+ let actual_match = actual.clone();
177
+ let actual_contain = actual.clone();
178
+ let actual_null = actual.clone();
179
+ let actual_truthy = actual.clone();
180
+ let actual_falsy = actual.clone();
181
+ let actual_defined = actual.clone();
182
+ let actual_len = actual.clone();
183
+ let actual_gt = actual.clone();
184
+ let actual_gte = actual.clone();
185
+ let actual_lt = actual.clone();
186
+ let actual_lte = actual.clone();
187
+ let actual_snap = actual.clone();
188
+ let actual_called = actual.clone();
189
+ let actual_called_times = actual.clone();
190
+ let actual_called_with = actual.clone();
191
+ let actual_last_with = actual.clone();
192
+
193
+ let mut m = ObjectMap::default();
194
+ m.insert(
195
+ Arc::from("toBe"),
196
+ Value::native(move |args: &[Value]| {
197
+ let expected = args.first().cloned().unwrap_or(Value::Null);
198
+ if !actual_be.strict_eq(&expected) {
199
+ return fail(
200
+ format!(
201
+ "expect(received).toBe(expected)\n\nExpected: {}\nReceived: {}",
202
+ expected.to_display_string(),
203
+ actual_be.to_display_string()
204
+ ),
205
+ actual_be.clone(),
206
+ Some(expected),
207
+ "toBe",
208
+ );
209
+ }
210
+ Value::Null
211
+ }),
212
+ );
213
+ m.insert(
214
+ Arc::from("toEqual"),
215
+ Value::native(move |args: &[Value]| {
216
+ let expected = args.first().cloned().unwrap_or(Value::Null);
217
+ if !deep_strict_equal(&actual_eq, &expected) {
218
+ return fail(
219
+ format!(
220
+ "expect(received).toEqual(expected)\n\nExpected: {}\nReceived: {}",
221
+ expected.to_display_string(),
222
+ actual_eq.to_display_string()
223
+ ),
224
+ actual_eq.clone(),
225
+ Some(expected),
226
+ "toEqual",
227
+ );
228
+ }
229
+ Value::Null
230
+ }),
231
+ );
232
+ m.insert(
233
+ Arc::from("toStrictEqual"),
234
+ Value::native(move |args: &[Value]| {
235
+ let expected = args.first().cloned().unwrap_or(Value::Null);
236
+ if !deep_strict_equal(&actual_strict, &expected) {
237
+ return fail(
238
+ format!(
239
+ "expect(received).toStrictEqual(expected)\n\nExpected: {}\nReceived: {}",
240
+ expected.to_display_string(),
241
+ actual_strict.to_display_string()
242
+ ),
243
+ actual_strict.clone(),
244
+ Some(expected),
245
+ "toStrictEqual",
246
+ );
247
+ }
248
+ Value::Null
249
+ }),
250
+ );
251
+ m.insert(
252
+ Arc::from("toThrow"),
253
+ Value::native(move |args: &[Value]| {
254
+ // A non-callable would make `value_call` set a pending throw, which used to read
255
+ // as "the code threw" and pass. Reject it up front instead.
256
+ if !is_callable(&actual_throw) {
257
+ return fail(
258
+ format!(
259
+ "expect(received).toThrow()\n\nReceived value is not a function: {}",
260
+ actual_throw.to_display_string()
261
+ ),
262
+ actual_throw.clone(),
263
+ None,
264
+ "toThrow",
265
+ );
266
+ }
267
+ let _ = take_pending_throw();
268
+ let _ = value_call(&actual_throw, &[]);
269
+ if !has_pending_throw() {
270
+ return fail(
271
+ "expect(received).toThrow()\n\nReceived function did not throw".into(),
272
+ actual_throw.clone(),
273
+ None,
274
+ "toThrow",
275
+ );
276
+ }
277
+ let err = take_pending_throw().unwrap_or(Value::Null);
278
+ if let Some(expected) = args.first() {
279
+ if !error_matches(&err, expected) {
280
+ return fail(
281
+ format!(
282
+ "expect(received).toThrow(expected)\n\nExpected: {}\nReceived: {}",
283
+ describe_error_matcher(expected),
284
+ error_text(&err)
285
+ ),
286
+ err,
287
+ Some(expected.clone()),
288
+ "toThrow",
289
+ );
290
+ }
291
+ }
292
+ Value::Null
293
+ }),
294
+ );
295
+ m.insert(
296
+ Arc::from("toMatch"),
297
+ Value::native(move |args: &[Value]| {
298
+ let s = actual_match.to_display_string();
299
+ let pattern = args.first().cloned().unwrap_or(Value::Null);
300
+ let ok = match &pattern {
301
+ #[cfg(feature = "regex")]
302
+ Value::RegExp(r) => r.borrow_mut().test(&s),
303
+ Value::String(p) => s.contains(p.as_str()),
304
+ other => s.contains(&other.to_display_string()),
305
+ };
306
+ if !ok {
307
+ return fail(
308
+ format!(
309
+ "expect(received).toMatch(expected)\n\nExpected pattern: {}\nReceived: {}",
310
+ pattern.to_display_string(),
311
+ s
312
+ ),
313
+ actual_match.clone(),
314
+ Some(pattern),
315
+ "toMatch",
316
+ );
317
+ }
318
+ Value::Null
319
+ }),
320
+ );
321
+ m.insert(
322
+ Arc::from("toContain"),
323
+ Value::native(move |args: &[Value]| {
324
+ let item = args.first().cloned().unwrap_or(Value::Null);
325
+ let ok = match &actual_contain {
326
+ Value::String(s) => s.as_str().contains(&item.to_display_string()),
327
+ Value::Array(a) => a.borrow().iter().any(|v| v.strict_eq(&item)),
328
+ Value::NumberArray(a) => a
329
+ .borrow()
330
+ .to_values()
331
+ .iter()
332
+ .any(|v| v.strict_eq(&item)),
333
+ _ => false,
334
+ };
335
+ if !ok {
336
+ return fail(
337
+ format!(
338
+ "expect(received).toContain(expected)\n\nExpected to contain: {}\nReceived: {}",
339
+ item.to_display_string(),
340
+ actual_contain.to_display_string()
341
+ ),
342
+ actual_contain.clone(),
343
+ Some(item),
344
+ "toContain",
345
+ );
346
+ }
347
+ Value::Null
348
+ }),
349
+ );
350
+ m.insert(
351
+ Arc::from("toBeNull"),
352
+ Value::native(move |_args: &[Value]| {
353
+ if !matches!(actual_null, Value::Null) {
354
+ return fail(
355
+ format!(
356
+ "expect(received).toBeNull()\n\nReceived: {}",
357
+ actual_null.to_display_string()
358
+ ),
359
+ actual_null.clone(),
360
+ Some(Value::Null),
361
+ "toBeNull",
362
+ );
363
+ }
364
+ Value::Null
365
+ }),
366
+ );
367
+ m.insert(
368
+ Arc::from("toBeTruthy"),
369
+ Value::native(move |_args: &[Value]| {
370
+ if !is_truthy(&actual_truthy) {
371
+ return fail(
372
+ format!(
373
+ "expect(received).toBeTruthy()\n\nReceived: {}",
374
+ actual_truthy.to_display_string()
375
+ ),
376
+ actual_truthy.clone(),
377
+ None,
378
+ "toBeTruthy",
379
+ );
380
+ }
381
+ Value::Null
382
+ }),
383
+ );
384
+ m.insert(
385
+ Arc::from("toBeFalsy"),
386
+ Value::native(move |_args: &[Value]| {
387
+ if is_truthy(&actual_falsy) {
388
+ return fail(
389
+ format!(
390
+ "expect(received).toBeFalsy()\n\nReceived: {}",
391
+ actual_falsy.to_display_string()
392
+ ),
393
+ actual_falsy.clone(),
394
+ None,
395
+ "toBeFalsy",
396
+ );
397
+ }
398
+ Value::Null
399
+ }),
400
+ );
401
+ // Tish has no `undefined`; `null` is the absence value, so these mirror `toBeNull`.
402
+ // Always-passing / always-failing versions were traps: ported Node suites got no signal.
403
+ m.insert(
404
+ Arc::from("toBeDefined"),
405
+ Value::native(move |_args: &[Value]| {
406
+ if matches!(actual_defined, Value::Null) {
407
+ return fail(
408
+ "expect(received).toBeDefined()\n\nReceived: null".into(),
409
+ actual_defined.clone(),
410
+ None,
411
+ "toBeDefined",
412
+ );
413
+ }
414
+ Value::Null
415
+ }),
416
+ );
417
+ let actual_undefined = actual.clone();
418
+ m.insert(
419
+ Arc::from("toBeUndefined"),
420
+ Value::native(move |_args: &[Value]| {
421
+ if !matches!(actual_undefined, Value::Null) {
422
+ return fail(
423
+ format!(
424
+ "expect(received).toBeUndefined()\n\nTish has no undefined; null is the absence value.\nReceived: {}",
425
+ actual_undefined.to_display_string()
426
+ ),
427
+ actual_undefined.clone(),
428
+ None,
429
+ "toBeUndefined",
430
+ );
431
+ }
432
+ Value::Null
433
+ }),
434
+ );
435
+ let actual_close = actual.clone();
436
+ m.insert(
437
+ Arc::from("toBeCloseTo"),
438
+ Value::native(move |args: &[Value]| {
439
+ let expected = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
440
+ let digits = args.get(1).and_then(|v| v.as_number()).unwrap_or(2.0);
441
+ let act = actual_close.as_number().unwrap_or(f64::NAN);
442
+ // Jest/Bun: |a - b| < 10^-digits / 2. The un-halved form accepted values Jest rejects.
443
+ let prec = 10f64.powf(-digits.max(0.0)) / 2.0;
444
+ if !((act - expected).abs() < prec) {
445
+ return fail(
446
+ format!(
447
+ "expect(received).toBeCloseTo({expected}, {digits})\n\nReceived: {act}"
448
+ ),
449
+ actual_close.clone(),
450
+ Some(Value::Number(expected)),
451
+ "toBeCloseTo",
452
+ );
453
+ }
454
+ Value::Null
455
+ }),
456
+ );
457
+ let actual_match_obj = actual.clone();
458
+ m.insert(
459
+ Arc::from("toMatchObject"),
460
+ Value::native(move |args: &[Value]| {
461
+ let expected = args.first().cloned().unwrap_or(Value::Null);
462
+ if !crate::deep_equal::partial_deep_strict_equal(&actual_match_obj, &expected) {
463
+ return fail(
464
+ format!(
465
+ "expect(received).toMatchObject(expected)\n\nExpected: {}\nReceived: {}",
466
+ expected.to_display_string(),
467
+ actual_match_obj.to_display_string()
468
+ ),
469
+ actual_match_obj.clone(),
470
+ Some(expected),
471
+ "toMatchObject",
472
+ );
473
+ }
474
+ Value::Null
475
+ }),
476
+ );
477
+ m.insert(
478
+ Arc::from("toHaveLength"),
479
+ Value::native(move |args: &[Value]| {
480
+ let expected = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
481
+ match length_of(&actual_len) {
482
+ Some(n) if (n - expected).abs() < f64::EPSILON => Value::Null,
483
+ Some(n) => fail(
484
+ format!(
485
+ "expect(received).toHaveLength({})\n\nReceived length: {}",
486
+ expected, n
487
+ ),
488
+ actual_len.clone(),
489
+ Some(Value::Number(expected)),
490
+ "toHaveLength",
491
+ ),
492
+ None => fail(
493
+ format!(
494
+ "expect(received).toHaveLength()\n\nReceived value has no length: {}",
495
+ actual_len.to_display_string()
496
+ ),
497
+ actual_len.clone(),
498
+ Some(Value::Number(expected)),
499
+ "toHaveLength",
500
+ ),
501
+ }
502
+ }),
503
+ );
504
+ m.insert(
505
+ Arc::from("toBeGreaterThan"),
506
+ Value::native(move |args: &[Value]| {
507
+ let exp = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
508
+ let act = actual_gt.as_number().unwrap_or(f64::NAN);
509
+ if !(act > exp) {
510
+ return fail(
511
+ format!("expect({act}).toBeGreaterThan({exp})"),
512
+ actual_gt.clone(),
513
+ Some(Value::Number(exp)),
514
+ "toBeGreaterThan",
515
+ );
516
+ }
517
+ Value::Null
518
+ }),
519
+ );
520
+ m.insert(
521
+ Arc::from("toBeGreaterThanOrEqual"),
522
+ Value::native(move |args: &[Value]| {
523
+ let exp = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
524
+ let act = actual_gte.as_number().unwrap_or(f64::NAN);
525
+ if !(act >= exp) {
526
+ return fail(
527
+ format!("expect({act}).toBeGreaterThanOrEqual({exp})"),
528
+ actual_gte.clone(),
529
+ Some(Value::Number(exp)),
530
+ "toBeGreaterThanOrEqual",
531
+ );
532
+ }
533
+ Value::Null
534
+ }),
535
+ );
536
+ m.insert(
537
+ Arc::from("toBeLessThan"),
538
+ Value::native(move |args: &[Value]| {
539
+ let exp = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
540
+ let act = actual_lt.as_number().unwrap_or(f64::NAN);
541
+ if !(act < exp) {
542
+ return fail(
543
+ format!("expect({act}).toBeLessThan({exp})"),
544
+ actual_lt.clone(),
545
+ Some(Value::Number(exp)),
546
+ "toBeLessThan",
547
+ );
548
+ }
549
+ Value::Null
550
+ }),
551
+ );
552
+ m.insert(
553
+ Arc::from("toBeLessThanOrEqual"),
554
+ Value::native(move |args: &[Value]| {
555
+ let exp = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
556
+ let act = actual_lte.as_number().unwrap_or(f64::NAN);
557
+ if !(act <= exp) {
558
+ return fail(
559
+ format!("expect({act}).toBeLessThanOrEqual({exp})"),
560
+ actual_lte.clone(),
561
+ Some(Value::Number(exp)),
562
+ "toBeLessThanOrEqual",
563
+ );
564
+ }
565
+ Value::Null
566
+ }),
567
+ );
568
+ m.insert(
569
+ Arc::from("toHaveBeenCalled"),
570
+ Value::native(move |_args: &[Value]| {
571
+ let Some(meta) = mock_meta(&actual_called) else {
572
+ return fail(
573
+ "expect(...).toHaveBeenCalled() requires a mock or spy".into(),
574
+ actual_called.clone(),
575
+ None,
576
+ "toHaveBeenCalled",
577
+ );
578
+ };
579
+ if mock_call_count(&meta) < 1.0 {
580
+ return fail(
581
+ "expect(mock).toHaveBeenCalled()".into(),
582
+ actual_called.clone(),
583
+ None,
584
+ "toHaveBeenCalled",
585
+ );
586
+ }
587
+ Value::Null
588
+ }),
589
+ );
590
+ m.insert(
591
+ Arc::from("toHaveBeenCalledTimes"),
592
+ Value::native(move |args: &[Value]| {
593
+ let Some(meta) = mock_meta(&actual_called_times) else {
594
+ return fail(
595
+ "expect(...).toHaveBeenCalledTimes() requires a mock or spy".into(),
596
+ actual_called_times.clone(),
597
+ None,
598
+ "toHaveBeenCalledTimes",
599
+ );
600
+ };
601
+ let expected = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
602
+ let got = mock_call_count(&meta);
603
+ if got != expected {
604
+ return fail(
605
+ format!("expect(mock).toHaveBeenCalledTimes({expected})\n\nReceived: {got}"),
606
+ actual_called_times.clone(),
607
+ Some(Value::Number(expected)),
608
+ "toHaveBeenCalledTimes",
609
+ );
610
+ }
611
+ Value::Null
612
+ }),
613
+ );
614
+ m.insert(
615
+ Arc::from("toHaveBeenCalledWith"),
616
+ Value::native(move |args: &[Value]| {
617
+ let Some(meta) = mock_meta(&actual_called_with) else {
618
+ return fail(
619
+ "expect(...).toHaveBeenCalledWith() requires a mock or spy".into(),
620
+ actual_called_with.clone(),
621
+ None,
622
+ "toHaveBeenCalledWith",
623
+ );
624
+ };
625
+ let expected = args.to_vec();
626
+ let ok = mock_calls(&meta).iter().any(|call| match call {
627
+ Value::Array(a) => {
628
+ let got = a.borrow();
629
+ got.len() == expected.len()
630
+ && got
631
+ .iter()
632
+ .zip(expected.iter())
633
+ .all(|(g, e)| deep_strict_equal(g, e))
634
+ }
635
+ _ => false,
636
+ });
637
+ if !ok {
638
+ return fail(
639
+ format!(
640
+ "expect(mock).toHaveBeenCalledWith({})\n\nNumber of calls: {}",
641
+ expected
642
+ .iter()
643
+ .map(|v| v.to_display_string())
644
+ .collect::<Vec<_>>()
645
+ .join(", "),
646
+ mock_call_count(&meta)
647
+ ),
648
+ actual_called_with.clone(),
649
+ None,
650
+ "toHaveBeenCalledWith",
651
+ );
652
+ }
653
+ Value::Null
654
+ }),
655
+ );
656
+ m.insert(
657
+ Arc::from("toHaveBeenLastCalledWith"),
658
+ Value::native(move |args: &[Value]| {
659
+ let Some(meta) = mock_meta(&actual_last_with) else {
660
+ return fail(
661
+ "expect(...).toHaveBeenLastCalledWith() requires a mock or spy".into(),
662
+ actual_last_with.clone(),
663
+ None,
664
+ "toHaveBeenLastCalledWith",
665
+ );
666
+ };
667
+ let expected = args.to_vec();
668
+ let calls = mock_calls(&meta);
669
+ let Some(last) = calls.last() else {
670
+ return fail(
671
+ "expect(mock).toHaveBeenLastCalledWith(...)\n\nNumber of calls: 0".into(),
672
+ actual_last_with.clone(),
673
+ None,
674
+ "toHaveBeenLastCalledWith",
675
+ );
676
+ };
677
+ let ok = match last {
678
+ Value::Array(a) => {
679
+ let got = a.borrow();
680
+ got.len() == expected.len()
681
+ && got
682
+ .iter()
683
+ .zip(expected.iter())
684
+ .all(|(g, e)| deep_strict_equal(g, e))
685
+ }
686
+ _ => false,
687
+ };
688
+ if !ok {
689
+ return fail(
690
+ "expect(mock).toHaveBeenLastCalledWith(...)".into(),
691
+ actual_last_with.clone(),
692
+ None,
693
+ "toHaveBeenLastCalledWith",
694
+ );
695
+ }
696
+ Value::Null
697
+ }),
698
+ );
699
+ m.insert(
700
+ Arc::from("toMatchSnapshot"),
701
+ Value::native(move |args: &[Value]| {
702
+ let hint = args.first().map(|v| v.to_display_string());
703
+ crate::snapshots::match_snapshot(&actual_snap, hint.as_deref())
704
+ }),
705
+ );
706
+ let actual_nan = actual.clone();
707
+ m.insert(
708
+ Arc::from("toBeNaN"),
709
+ Value::native(move |_args: &[Value]| {
710
+ if !matches!(actual_nan, Value::Number(n) if n.is_nan()) {
711
+ return fail(
712
+ format!(
713
+ "expect(received).toBeNaN()\n\nReceived: {}",
714
+ actual_nan.to_display_string()
715
+ ),
716
+ actual_nan.clone(),
717
+ None,
718
+ "toBeNaN",
719
+ );
720
+ }
721
+ Value::Null
722
+ }),
723
+ );
724
+ let actual_typeof = actual.clone();
725
+ m.insert(
726
+ Arc::from("toBeTypeOf"),
727
+ Value::native(move |args: &[Value]| {
728
+ let want = args
729
+ .first()
730
+ .map(|v| v.to_display_string())
731
+ .unwrap_or_default();
732
+ let got = type_of_name(&actual_typeof);
733
+ if got != want {
734
+ return fail(
735
+ format!("expect(received).toBeTypeOf({want:?})\n\nReceived type: {got}"),
736
+ actual_typeof.clone(),
737
+ None,
738
+ "toBeTypeOf",
739
+ );
740
+ }
741
+ Value::Null
742
+ }),
743
+ );
744
+ let actual_prop = actual.clone();
745
+ m.insert(
746
+ Arc::from("toHaveProperty"),
747
+ Value::native(move |args: &[Value]| {
748
+ let path = args.first().cloned().unwrap_or(Value::Null);
749
+ let Some(found) = property_at(&actual_prop, &path) else {
750
+ return fail(
751
+ format!(
752
+ "expect(received).toHaveProperty({})\n\nProperty not found on: {}",
753
+ path.to_display_string(),
754
+ actual_prop.to_display_string()
755
+ ),
756
+ actual_prop.clone(),
757
+ None,
758
+ "toHaveProperty",
759
+ );
760
+ };
761
+ if let Some(expected) = args.get(1) {
762
+ if !deep_strict_equal(&found, expected) {
763
+ return fail(
764
+ format!(
765
+ "expect(received).toHaveProperty({}, expected)\n\nExpected: {}\nReceived: {}",
766
+ path.to_display_string(),
767
+ expected.to_display_string(),
768
+ found.to_display_string()
769
+ ),
770
+ found,
771
+ Some(expected.clone()),
772
+ "toHaveProperty",
773
+ );
774
+ }
775
+ }
776
+ Value::Null
777
+ }),
778
+ );
779
+ let actual_contain_eq = actual.clone();
780
+ m.insert(
781
+ Arc::from("toContainEqual"),
782
+ Value::native(move |args: &[Value]| {
783
+ let item = args.first().cloned().unwrap_or(Value::Null);
784
+ let items = match actual_contain_eq.clone().coerce_number_array() {
785
+ Value::Array(a) => a.borrow().clone(),
786
+ _ => Vec::new(),
787
+ };
788
+ if !items.iter().any(|v| deep_strict_equal(v, &item)) {
789
+ return fail(
790
+ format!(
791
+ "expect(received).toContainEqual(expected)\n\nExpected to contain: {}\nReceived: {}",
792
+ item.to_display_string(),
793
+ actual_contain_eq.to_display_string()
794
+ ),
795
+ actual_contain_eq.clone(),
796
+ Some(item),
797
+ "toContainEqual",
798
+ );
799
+ }
800
+ Value::Null
801
+ }),
802
+ );
803
+
804
+ // `toThrowError` is Jest's alias for `toThrow`.
805
+ if let Some(t) = m.get("toThrow").cloned() {
806
+ m.insert(Arc::from("toThrowError"), t);
807
+ }
808
+
809
+ attach_not(&mut m, actual);
810
+ Value::object(m)
811
+ }
812
+
813
+ /// Build `expect(x).not.*` by inverting each matcher via pending-throw polarity.
814
+ fn attach_not(m: &mut ObjectMap, actual: Value) {
815
+ let mut not_map = ObjectMap::default();
816
+ let keys: Vec<Arc<str>> = m.keys().cloned().collect();
817
+ for key in keys {
818
+ // `.not.toMatchSnapshot()` would write (or diff-invert) a snapshot; Jest has no such
819
+ // matcher either.
820
+ if key.as_ref() == "not" || key.as_ref() == "toMatchSnapshot" {
821
+ continue;
822
+ }
823
+ let Some(matcher) = m.get(key.as_ref()).cloned() else {
824
+ continue;
825
+ };
826
+ let act = actual.clone();
827
+ let name = key.to_string();
828
+ not_map.insert(
829
+ Arc::clone(&key),
830
+ Value::native(move |args: &[Value]| {
831
+ let _ = take_pending_throw();
832
+ let _ = value_call(&matcher, args);
833
+ if has_pending_throw() {
834
+ let _ = take_pending_throw();
835
+ return Value::Null;
836
+ }
837
+ fail(
838
+ format!("expect(received).not.{name}() expected matcher to fail"),
839
+ act.clone(),
840
+ None,
841
+ &format!("not.{name}"),
842
+ )
843
+ }),
844
+ );
845
+ }
846
+ m.insert(Arc::from("not"), Value::object(not_map));
847
+ }
848
+
849
+ thread_local! {
850
+ static ASSERTIONS_MADE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
851
+ /// From `expect.assertions(n)`.
852
+ static ASSERTIONS_EXPECTED: std::cell::Cell<Option<usize>> =
853
+ const { std::cell::Cell::new(None) };
854
+ /// From `expect.hasAssertions()` — "at least one".
855
+ static ASSERTIONS_AT_LEAST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
856
+ }
857
+
858
+ /// Reset the per-test assertion counters. Called by the runner before each test body.
859
+ pub fn begin_test_assertions() {
860
+ ASSERTIONS_MADE.with(|c| c.set(0));
861
+ ASSERTIONS_EXPECTED.with(|c| c.set(None));
862
+ ASSERTIONS_AT_LEAST.with(|c| c.set(false));
863
+ }
864
+
865
+ /// Verify an `expect.assertions(n)` / `expect.hasAssertions()` contract for the test that just ran.
866
+ pub fn check_test_assertions() -> Result<(), String> {
867
+ let made = ASSERTIONS_MADE.with(|c| c.get());
868
+ if ASSERTIONS_AT_LEAST.with(|c| c.get()) && made == 0 {
869
+ return Err("expect.hasAssertions(): no assertions were made".to_string());
870
+ }
871
+ if let Some(want) = ASSERTIONS_EXPECTED.with(|c| c.get()) {
872
+ if made != want {
873
+ return Err(format!(
874
+ "expect.assertions({want}): {made} assertion(s) were made"
875
+ ));
876
+ }
877
+ }
878
+ Ok(())
879
+ }
880
+
881
+ fn expect_call(args: &[Value]) -> Value {
882
+ ASSERTIONS_MADE.with(|c| c.set(c.get() + 1));
883
+ let actual = args.first().cloned().unwrap_or(Value::Null);
884
+ matcher_object(actual)
885
+ }
886
+
887
+ fn asymmetric_any(args: &[Value]) -> Value {
888
+ let type_name = args
889
+ .first()
890
+ .map(|v| v.to_display_string())
891
+ .unwrap_or_else(|| "Object".into());
892
+ let mut m = ObjectMap::default();
893
+ m.insert(Arc::from(ASYMMETRIC_KEY), Value::String("any".into()));
894
+ m.insert(Arc::from("typeName"), Value::String(type_name.into()));
895
+ Value::object(m)
896
+ }
897
+
898
+ fn asymmetric_object_containing(args: &[Value]) -> Value {
899
+ let sample = args.first().cloned().unwrap_or(Value::Null);
900
+ let mut m = ObjectMap::default();
901
+ m.insert(
902
+ Arc::from(ASYMMETRIC_KEY),
903
+ Value::String("objectContaining".into()),
904
+ );
905
+ m.insert(Arc::from("sample"), sample);
906
+ Value::object(m)
907
+ }
908
+
909
+ fn asymmetric_array_containing(args: &[Value]) -> Value {
910
+ let sample = args.first().cloned().unwrap_or(Value::Null);
911
+ let mut m = ObjectMap::default();
912
+ m.insert(
913
+ Arc::from(ASYMMETRIC_KEY),
914
+ Value::String("arrayContaining".into()),
915
+ );
916
+ m.insert(Arc::from("sample"), sample);
917
+ Value::object(m)
918
+ }
919
+
920
+ fn asymmetric_string_matching(args: &[Value]) -> Value {
921
+ let sample = args.first().cloned().unwrap_or(Value::Null);
922
+ let mut m = ObjectMap::default();
923
+ m.insert(
924
+ Arc::from(ASYMMETRIC_KEY),
925
+ Value::String("stringMatching".into()),
926
+ );
927
+ m.insert(Arc::from("sample"), sample);
928
+ Value::object(m)
929
+ }
930
+
931
+ /// Marker key for `expect.any` / `objectContaining` / … Deliberately *not* React's `$$typeof`,
932
+ /// which a plain user object may legitimately carry — treating those as matchers made them
933
+ /// compare unequal to everything.
934
+ pub(crate) const ASYMMETRIC_KEY: &str = "__tishAsymmetricMatcher";
935
+
936
+ /// Build the callable `expect` object.
937
+ pub fn expect_object() -> Value {
938
+ let mut m = ObjectMap::default();
939
+ m.insert(Arc::from("__call"), Value::native(expect_call));
940
+ m.insert(
941
+ Arc::from("assertions"),
942
+ Value::native(|args: &[Value]| {
943
+ let n = args.first().and_then(|v| v.as_number()).unwrap_or(0.0);
944
+ ASSERTIONS_EXPECTED.with(|c| c.set(Some(n.max(0.0) as usize)));
945
+ Value::Null
946
+ }),
947
+ );
948
+ m.insert(
949
+ Arc::from("hasAssertions"),
950
+ Value::native(|_args: &[Value]| {
951
+ ASSERTIONS_AT_LEAST.with(|c| c.set(true));
952
+ Value::Null
953
+ }),
954
+ );
955
+ m.insert(Arc::from("any"), Value::native(asymmetric_any));
956
+ m.insert(
957
+ Arc::from("anything"),
958
+ Value::native(|_args: &[Value]| {
959
+ let mut o = ObjectMap::default();
960
+ o.insert(Arc::from(ASYMMETRIC_KEY), Value::String("anything".into()));
961
+ Value::object(o)
962
+ }),
963
+ );
964
+ m.insert(
965
+ Arc::from("objectContaining"),
966
+ Value::native(asymmetric_object_containing),
967
+ );
968
+ m.insert(
969
+ Arc::from("arrayContaining"),
970
+ Value::native(asymmetric_array_containing),
971
+ );
972
+ m.insert(
973
+ Arc::from("stringMatching"),
974
+ Value::native(asymmetric_string_matching),
975
+ );
976
+ Value::object(m)
977
+ }