@coherent.js/tooling 1.1.0 → 2.0.0-rc.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,5 +1,8 @@
1
1
  // src/testing/test-renderer.js
2
2
  import { render } from "@coherent.js/core";
3
+ function escapeRegExp(text) {
4
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
3
6
  var TestRendererResult = class {
4
7
  constructor(component, html, container = null) {
5
8
  this.component = component;
@@ -13,7 +16,7 @@ var TestRendererResult = class {
13
16
  * @returns {Object|null} Element or null
14
17
  */
15
18
  getByTestId(testId) {
16
- const regex = new RegExp(`data-testid="${testId}"[^>]*>([^<]*)<`, "i");
19
+ const regex = new RegExp(`<[a-zA-Z][\\w:-]*(?:\\s[^>]*?)?\\sdata-testid="${escapeRegExp(testId)}"[^>]*>([^<]*)<`, "i");
17
20
  const match = this.html.match(regex);
18
21
  if (!match) {
19
22
  throw new Error(`Unable to find element with testId: ${testId}`);
@@ -43,7 +46,7 @@ var TestRendererResult = class {
43
46
  * @returns {Object} Element
44
47
  */
45
48
  getByText(text) {
46
- const regex = typeof text === "string" ? new RegExp(`>([^<]*${text}[^<]*)<`, "i") : new RegExp(`>([^<]*)<`, "i");
49
+ const regex = typeof text === "string" ? new RegExp(`>([^<]*${escapeRegExp(text)}[^<]*)<`, "i") : new RegExp(`>([^<]*)<`, "i");
47
50
  const match = this.html.match(regex);
48
51
  if (!match || typeof text === "string" && !match[1].includes(text)) {
49
52
  throw new Error(`Unable to find element with text: ${text}`);
@@ -72,7 +75,8 @@ var TestRendererResult = class {
72
75
  * @returns {Object} Element
73
76
  */
74
77
  getByClassName(className) {
75
- const regex = new RegExp(`class="[^"]*${className}[^"]*"[^>]*>([^<]*)<`, "i");
78
+ const token = escapeRegExp(className);
79
+ const regex = new RegExp(`<[a-zA-Z][\\w:-]*(?:\\s[^>]*?)?\\sclass="(?:[^"]*\\s)?${token}(?:\\s[^"]*)?"[^>]*>([^<]*)<`, "i");
76
80
  const match = this.html.match(regex);
77
81
  if (!match) {
78
82
  throw new Error(`Unable to find element with className: ${className}`);
@@ -102,7 +106,8 @@ var TestRendererResult = class {
102
106
  * @returns {Array<Object>} Array of elements
103
107
  */
104
108
  getAllByTagName(tagName) {
105
- const regex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, "gi");
109
+ const tag = escapeRegExp(tagName);
110
+ const regex = new RegExp(`<${tag}(?=[\\s/>])[^>]*>([^<]*)</${tag}>`, "gi");
106
111
  const matches = [...this.html.matchAll(regex)];
107
112
  return matches.map((match) => ({
108
113
  text: match[1],
@@ -348,6 +353,8 @@ function createMock(implementation) {
348
353
  implementation = () => Promise.reject(error);
349
354
  return mockFn;
350
355
  };
356
+ Object.defineProperty(mockFn, "_isMockFunction", { value: true });
357
+ mockFn.getMockName = () => "createMock()";
351
358
  return mockFn;
352
359
  }
353
360
  function createSpy(object, method) {
@@ -462,72 +469,214 @@ var userEvent = {
462
469
  };
463
470
 
464
471
  // src/testing/matchers.js
472
+ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
473
+ "area",
474
+ "base",
475
+ "br",
476
+ "col",
477
+ "embed",
478
+ "hr",
479
+ "img",
480
+ "input",
481
+ "keygen",
482
+ "link",
483
+ "meta",
484
+ "param",
485
+ "source",
486
+ "track",
487
+ "wbr"
488
+ ]);
489
+ var RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "textarea", "title"]);
490
+ var NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\xA0" };
491
+ function decodeEntities(text) {
492
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, body) => {
493
+ if (body[0] === "#") {
494
+ const codePoint = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
495
+ return codePoint <= 1114111 ? String.fromCodePoint(codePoint) : entity;
496
+ }
497
+ return NAMED_ENTITIES[body.toLowerCase()] ?? entity;
498
+ });
499
+ }
500
+ function htmlOf(received) {
501
+ if (typeof received === "string") return received;
502
+ if (received && typeof received.html === "string") return received.html;
503
+ return null;
504
+ }
505
+ function textOf(received) {
506
+ if (received && typeof received === "object" && typeof received.text === "string") {
507
+ return decodeEntities(received.text);
508
+ }
509
+ const html = htmlOf(received);
510
+ if (html === null) return null;
511
+ return decodeEntities(stripTags(html));
512
+ }
513
+ function stripTags(html) {
514
+ let out = "";
515
+ let cursor = 0;
516
+ while (cursor < html.length) {
517
+ const open = html.indexOf("<", cursor);
518
+ if (open === -1) break;
519
+ const close = html.indexOf(">", open + 1);
520
+ if (close === -1) break;
521
+ out += html.slice(cursor, open);
522
+ cursor = close + 1;
523
+ }
524
+ return out + html.slice(cursor);
525
+ }
526
+ var isSpace = (ch) => ch === " " || ch === "\n" || ch === " " || ch === "\r" || ch === "\f";
527
+ function parseOpeningTag(html) {
528
+ const start = html.search(/<[a-zA-Z]/);
529
+ if (start === -1) return null;
530
+ let i = start + 1;
531
+ let tagName = "";
532
+ while (i < html.length && /[\w:-]/.test(html[i])) tagName += html[i++];
533
+ const attributes = /* @__PURE__ */ new Map();
534
+ while (i < html.length) {
535
+ while (i < html.length && isSpace(html[i])) i++;
536
+ if (i >= html.length || html[i] === ">") break;
537
+ if (html[i] === "/") {
538
+ i++;
539
+ continue;
540
+ }
541
+ let name = "";
542
+ while (i < html.length && !isSpace(html[i]) && html[i] !== "=" && html[i] !== ">" && html[i] !== "/") {
543
+ name += html[i++];
544
+ }
545
+ while (i < html.length && isSpace(html[i])) i++;
546
+ let value = "";
547
+ if (html[i] === "=") {
548
+ i++;
549
+ while (i < html.length && isSpace(html[i])) i++;
550
+ const quote = html[i];
551
+ if (quote === '"' || quote === "'") {
552
+ const end = html.indexOf(quote, i + 1);
553
+ if (end === -1) break;
554
+ value = html.slice(i + 1, end);
555
+ i = end + 1;
556
+ } else {
557
+ while (i < html.length && !isSpace(html[i]) && html[i] !== ">") value += html[i++];
558
+ }
559
+ }
560
+ if (name) attributes.set(name.toLowerCase(), decodeEntities(value));
561
+ }
562
+ return { tagName: tagName.toLowerCase(), attributes };
563
+ }
564
+ function classesOf(received) {
565
+ const html = htmlOf(received);
566
+ const tag = html === null ? null : parseOpeningTag(html);
567
+ let value = tag?.attributes.get("class");
568
+ if (value === void 0 && typeof received?.className === "string") value = received.className;
569
+ return value === void 0 ? null : value.split(/\s+/).filter(Boolean);
570
+ }
571
+ function findHTMLError(html) {
572
+ const stack = [];
573
+ let lower = null;
574
+ let i = 0;
575
+ while ((i = html.indexOf("<", i)) !== -1) {
576
+ if (html.startsWith("<!--", i)) {
577
+ const end = html.indexOf("-->", i + 4);
578
+ if (end === -1) return "unterminated comment";
579
+ i = end + 3;
580
+ continue;
581
+ }
582
+ const close = html.indexOf(">", i);
583
+ if (close === -1) return "unterminated tag";
584
+ const tag = html.slice(i + 1, close);
585
+ i = close + 1;
586
+ if (tag[0] === "!" || tag[0] === "?") continue;
587
+ const match = /^(\/?)([a-zA-Z][\w:-]*)/.exec(tag);
588
+ if (!match) continue;
589
+ const name = match[2].toLowerCase();
590
+ if (match[1]) {
591
+ if (VOID_ELEMENTS.has(name)) return `</${name}> closes a void element`;
592
+ const open = stack.pop();
593
+ if (open !== name) return open ? `</${name}> does not close <${open}>` : `</${name}> has no opening tag`;
594
+ } else if (!VOID_ELEMENTS.has(name) && !tag.endsWith("/")) {
595
+ stack.push(name);
596
+ if (RAW_TEXT_ELEMENTS.has(name)) {
597
+ lower ??= html.toLowerCase();
598
+ const end = lower.indexOf(`</${name}`, i);
599
+ if (end === -1) return `<${name}> is never closed`;
600
+ i = end;
601
+ }
602
+ }
603
+ }
604
+ return stack.length > 0 ? `<${stack[stack.length - 1]}> is never closed` : null;
605
+ }
606
+ var show = (value) => value === null || value === void 0 ? "nothing" : JSON.stringify(value);
465
607
  var customMatchers = {
466
608
  /**
467
- * Check if element has specific text
609
+ * Check if element (or a render result) has exactly this text content
468
610
  */
469
611
  toHaveText(received, expected) {
470
- const pass = received && received.text === expected;
612
+ const text = textOf(received);
613
+ const pass = text === expected;
471
614
  return {
472
615
  pass,
473
- message: () => pass ? `Expected element not to have text "${expected}"` : `Expected element to have text "${expected}", but got "${received?.text || "null"}"`
616
+ message: () => pass ? `Expected element not to have text "${expected}"` : `Expected element to have text "${expected}", but got ${show(text)}`
474
617
  };
475
618
  },
476
619
  /**
477
- * Check if element contains text
620
+ * Check if element (or a render result) contains text
478
621
  */
479
622
  toContainText(received, expected) {
480
- const pass = received && received.text && received.text.includes(expected);
623
+ const text = textOf(received);
624
+ const pass = typeof text === "string" && text.includes(expected);
481
625
  return {
482
626
  pass,
483
- message: () => pass ? `Expected element not to contain text "${expected}"` : `Expected element to contain text "${expected}", but got "${received?.text || "null"}"`
627
+ message: () => pass ? `Expected element not to contain text "${expected}"` : `Expected element to contain text "${expected}", but got ${show(text)}`
484
628
  };
485
629
  },
486
630
  /**
487
- * Check if element has specific class
631
+ * Check if the element has every given class (whole class tokens:
632
+ * 'btn' does not match 'btn-primary')
488
633
  */
489
634
  toHaveClass(received, expected) {
490
- const pass = received && received.className && received.className.includes(expected);
635
+ const classes = classesOf(received) ?? [];
636
+ const wanted = String(expected).split(/\s+/).filter(Boolean);
637
+ const pass = wanted.length > 0 && wanted.every((name) => classes.includes(name));
491
638
  return {
492
639
  pass,
493
- message: () => pass ? `Expected element not to have class "${expected}"` : `Expected element to have class "${expected}", but got "${received?.className || "null"}"`
640
+ message: () => pass ? `Expected element not to have class "${expected}"` : `Expected element to have class "${expected}", but its classes are ${show(classes.join(" "))}`
494
641
  };
495
642
  },
496
643
  /**
497
644
  * Check if element exists
498
645
  */
499
646
  toBeInTheDocument(received) {
500
- const pass = received && received.exists === true;
647
+ const pass = Boolean(received && received.exists === true);
501
648
  return {
502
649
  pass,
503
650
  message: () => pass ? "Expected element not to be in the document" : "Expected element to be in the document"
504
651
  };
505
652
  },
506
653
  /**
507
- * Check if element is visible (has content)
654
+ * Check if element is visible (has text content)
508
655
  */
509
656
  toBeVisible(received) {
510
- const pass = received && received.text && received.text.trim().length > 0;
657
+ const text = textOf(received);
658
+ const pass = typeof text === "string" && text.trim().length > 0;
511
659
  return {
512
660
  pass,
513
661
  message: () => pass ? "Expected element not to be visible" : "Expected element to be visible (have text content)"
514
662
  };
515
663
  },
516
664
  /**
517
- * Check if element is empty
665
+ * Check if element is empty (no text content)
518
666
  */
519
667
  toBeEmpty(received) {
520
- const pass = !received || !received.text || received.text.trim().length === 0;
668
+ const text = textOf(received);
669
+ const pass = !text || text.trim().length === 0;
521
670
  return {
522
671
  pass,
523
- message: () => pass ? "Expected element not to be empty" : "Expected element to be empty"
672
+ message: () => pass ? "Expected element not to be empty" : `Expected element to be empty, but it has text ${show(text)}`
524
673
  };
525
674
  },
526
675
  /**
527
676
  * Check if HTML contains specific string
528
677
  */
529
678
  toContainHTML(received, expected) {
530
- const html = received?.html || received;
679
+ const html = htmlOf(received);
531
680
  const pass = typeof html === "string" && html.includes(expected);
532
681
  return {
533
682
  pass,
@@ -535,113 +684,71 @@ var customMatchers = {
535
684
  };
536
685
  },
537
686
  /**
538
- * Check if element has attribute
687
+ * Check if the element has an attribute (optionally with this value)
539
688
  */
540
689
  toHaveAttribute(received, attribute, value) {
541
- const html = received?.html || "";
542
- const regex = new RegExp(`${attribute}="([^"]*)"`, "i");
543
- const match = html.match(regex);
544
- const pass = value !== void 0 ? match && match[1] === value : match !== null;
690
+ const html = htmlOf(received);
691
+ const tag = html === null ? null : parseOpeningTag(html);
692
+ const name = String(attribute).toLowerCase();
693
+ const has = Boolean(tag?.attributes.has(name));
694
+ const actual = has ? tag.attributes.get(name) : void 0;
695
+ const pass = value !== void 0 ? has && actual === String(value) : has;
545
696
  return {
546
697
  pass,
547
698
  message: () => {
548
699
  if (value !== void 0) {
549
- return pass ? `Expected element not to have attribute ${attribute}="${value}"` : `Expected element to have attribute ${attribute}="${value}", but got "${match?.[1] || "none"}"`;
700
+ return pass ? `Expected element not to have attribute ${attribute}="${value}"` : `Expected element to have attribute ${attribute}="${value}", but got ${has ? show(actual) : "none"}`;
550
701
  }
551
702
  return pass ? `Expected element not to have attribute ${attribute}` : `Expected element to have attribute ${attribute}`;
552
703
  }
553
704
  };
554
705
  },
555
706
  /**
556
- * Check if component matches snapshot
557
- */
558
- toMatchSnapshot(received) {
559
- const _snapshot = received?.toSnapshot ? received.toSnapshot() : received;
560
- return {
561
- pass: true,
562
- message: () => "Snapshot comparison"
563
- };
564
- },
565
- /**
566
- * Check if element has specific tag name
707
+ * Check if the element has this tag name
567
708
  */
568
709
  toHaveTagName(received, tagName) {
569
- const html = received?.html || "";
570
- const regex = new RegExp(`<${tagName}[^>]*>`, "i");
571
- const pass = regex.test(html);
710
+ const html = htmlOf(received);
711
+ const tag = html === null ? null : parseOpeningTag(html);
712
+ const pass = Boolean(tag) && tag.tagName === String(tagName).toLowerCase();
572
713
  return {
573
714
  pass,
574
- message: () => pass ? `Expected element not to have tag name "${tagName}"` : `Expected element to have tag name "${tagName}"`
715
+ message: () => pass ? `Expected element not to have tag name "${tagName}"` : `Expected element to have tag name "${tagName}", but got ${show(tag?.tagName)}`
575
716
  };
576
717
  },
577
718
  /**
578
719
  * Check if render result contains element
579
720
  */
580
721
  toContainElement(received, element) {
581
- const html = received?.html || received;
582
- const elementHtml = element?.html || element;
583
- const pass = typeof html === "string" && html.includes(elementHtml);
722
+ const html = htmlOf(received);
723
+ const elementHtml = htmlOf(element);
724
+ const pass = typeof html === "string" && typeof elementHtml === "string" && html.includes(elementHtml);
584
725
  return {
585
726
  pass,
586
727
  message: () => pass ? "Expected not to contain element" : "Expected to contain element"
587
728
  };
588
729
  },
589
- /**
590
- * Check if mock was called
591
- */
592
- toHaveBeenCalled(received) {
593
- const pass = received?.mock?.calls?.length > 0;
594
- return {
595
- pass,
596
- message: () => pass ? "Expected mock not to have been called" : "Expected mock to have been called"
597
- };
598
- },
599
- /**
600
- * Check if mock was called with specific args
601
- */
602
- toHaveBeenCalledWith(received, ...expectedArgs) {
603
- const calls = received?.mock?.calls || [];
604
- const pass = calls.some(
605
- (call) => call.length === expectedArgs.length && call.every((arg, i) => arg === expectedArgs[i])
606
- );
607
- return {
608
- pass,
609
- message: () => pass ? `Expected mock not to have been called with ${JSON.stringify(expectedArgs)}` : `Expected mock to have been called with ${JSON.stringify(expectedArgs)}`
610
- };
611
- },
612
- /**
613
- * Check if mock was called N times
614
- */
615
- toHaveBeenCalledTimes(received, times) {
616
- const callCount = received?.mock?.calls?.length || 0;
617
- const pass = callCount === times;
618
- return {
619
- pass,
620
- message: () => pass ? `Expected mock not to have been called ${times} times` : `Expected mock to have been called ${times} times, but was called ${callCount} times`
621
- };
622
- },
623
730
  /**
624
731
  * Check if component rendered successfully
625
732
  */
626
733
  toRenderSuccessfully(received) {
627
- const pass = received && received.html && received.html.length > 0;
734
+ const html = htmlOf(received);
735
+ const pass = typeof html === "string" && html.length > 0;
628
736
  return {
629
737
  pass,
630
738
  message: () => pass ? "Expected component not to render successfully" : "Expected component to render successfully"
631
739
  };
632
740
  },
633
741
  /**
634
- * Check if HTML is valid
742
+ * Check that every tag is closed in order. Void elements (<input>, <br>,
743
+ * <img>, …) need no closing tag.
635
744
  */
636
745
  toBeValidHTML(received) {
637
- const html = received?.html || received;
638
- const openTags = (html.match(/<[^/][^>]*>/g) || []).length;
639
- const closeTags = (html.match(/<\/[^>]+>/g) || []).length;
640
- const selfClosing = (html.match(/<[^>]+\/>/g) || []).length;
641
- const pass = openTags === closeTags + selfClosing;
746
+ const html = htmlOf(received);
747
+ const error = typeof html === "string" ? findHTMLError(html) : "received no HTML";
748
+ const pass = error === null;
642
749
  return {
643
750
  pass,
644
- message: () => pass ? "Expected HTML not to be valid" : `Expected HTML to be valid (open: ${openTags}, close: ${closeTags}, self-closing: ${selfClosing})`
751
+ message: () => pass ? "Expected HTML not to be valid" : `Expected HTML to be valid, but ${error}`
645
752
  };
646
753
  }
647
754
  };
@@ -657,8 +764,9 @@ var assertions = {
657
764
  * Assert element has text
658
765
  */
659
766
  assertHasText(element, text) {
660
- if (!element || element.text !== text) {
661
- throw new Error(`Expected element to have text "${text}", but got "${element?.text || "null"}"`);
767
+ const actual = textOf(element);
768
+ if (actual !== text) {
769
+ throw new Error(`Expected element to have text "${text}", but got ${show(actual)}`);
662
770
  }
663
771
  },
664
772
  /**
@@ -670,10 +778,10 @@ var assertions = {
670
778
  }
671
779
  },
672
780
  /**
673
- * Assert element has class
781
+ * Assert element has class (a whole class token)
674
782
  */
675
783
  assertHasClass(element, className) {
676
- if (!element || !element.className || !element.className.includes(className)) {
784
+ if (!(classesOf(element) ?? []).includes(className)) {
677
785
  throw new Error(`Expected element to have class "${className}"`);
678
786
  }
679
787
  },
@@ -681,7 +789,7 @@ var assertions = {
681
789
  * Assert HTML contains string
682
790
  */
683
791
  assertContainsHTML(html, substring) {
684
- const htmlString = html?.html || html;
792
+ const htmlString = htmlOf(html);
685
793
  if (!htmlString || !htmlString.includes(substring)) {
686
794
  throw new Error(`Expected HTML to contain "${substring}"`);
687
795
  }