pytest-html-plus 1.3.0__tar.gz → 1.4.0__tar.gz

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,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-html-plus
3
- Version: 1.3.0
4
- Summary: Generate Actionable, automatic screenshots, unified Mobile friendly Pytest HTML report in less than 3 seconds — no hooks, merge plugins, no config, xdist-ready.
3
+ Version: 1.4.0
4
+ Summary: Self contained pytest HTML reports with automatic screenshots, similar-error detection, shareable test links, and xdist support. No reporting server or complex setup.
5
5
  License: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: pytest,pytest-plugin,html-report,test-report,screenshot,mobile-report,xdist,allure-alternative,playwright,selenium
@@ -32,11 +32,9 @@ Project-URL: Source, https://github.com/reporterplus/pytest-html-plus
32
32
  Project-URL: Tracker, https://github.com/reporterplus/pytest-html-plus/issues
33
33
  Description-Content-Type: text/markdown
34
34
 
35
- ⚡ **Test your code, not your reporting setup.**
35
+ **Find the failure. Spot the pattern. Debug together.**
36
36
 
37
- > A pytest report built for fast debugging, with parallel execution support, automatic screenshots, and shareable failure context.
38
-
39
- `pytest-html-plus` turns pytest results into portable debugging artifacts for CI, collaboration, and integrations, without a reporting platform.
37
+ A self-contained pytest report for faster debugging, with automatic screenshots, similar-error navigation, shareable test links, and parallel execution support
40
38
 
41
39
  ➡️ [View Demo Report](https://reporterplus.github.io/pytest-html-plus/)
42
40
 
@@ -95,6 +93,13 @@ Copy a direct link to any test result and share it with your team. The report op
95
93
 
96
94
  <img width="3024" height="1778" alt="ScreenRecording2026-10-03at8 55 26AM-ezgif com-cut" src="https://github.com/user-attachments/assets/0c44a38f-7898-4a4d-8241-4f1d1c57ccfb" />
97
95
 
96
+ #### Similar Errors — Spot repeated failure patterns without inspecting every test
97
+
98
+ The layers badge identifies tests with matching failure messages and lets you navigate between their results. Similar messages may have different causes. Enabled by default; disable with `--plus-similar-errors=false`.
99
+
100
+ <img width="800" height="381" alt="Screen Recording 2026-10-31 at 10 10 37 PM" src="https://github.com/user-attachments/assets/833b68ce-64de-4675-9421-6fe5e6b78d6e" />
101
+
102
+
98
103
  #### 🔍 Find the Context You Need
99
104
 
100
105
  Search tests, errors, traces, issue references, documentation links, and custom references from the same report.
@@ -1,8 +1,6 @@
1
- ⚡ **Test your code, not your reporting setup.**
1
+ **Find the failure. Spot the pattern. Debug together.**
2
2
 
3
- > A pytest report built for fast debugging, with parallel execution support, automatic screenshots, and shareable failure context.
4
-
5
- `pytest-html-plus` turns pytest results into portable debugging artifacts for CI, collaboration, and integrations, without a reporting platform.
3
+ A self-contained pytest report for faster debugging, with automatic screenshots, similar-error navigation, shareable test links, and parallel execution support
6
4
 
7
5
  ➡️ [View Demo Report](https://reporterplus.github.io/pytest-html-plus/)
8
6
 
@@ -61,6 +59,13 @@ Copy a direct link to any test result and share it with your team. The report op
61
59
 
62
60
  <img width="3024" height="1778" alt="ScreenRecording2026-10-03at8 55 26AM-ezgif com-cut" src="https://github.com/user-attachments/assets/0c44a38f-7898-4a4d-8241-4f1d1c57ccfb" />
63
61
 
62
+ #### Similar Errors — Spot repeated failure patterns without inspecting every test
63
+
64
+ The layers badge identifies tests with matching failure messages and lets you navigate between their results. Similar messages may have different causes. Enabled by default; disable with `--plus-similar-errors=false`.
65
+
66
+ <img width="800" height="381" alt="Screen Recording 2026-10-31 at 10 10 37 PM" src="https://github.com/user-attachments/assets/833b68ce-64de-4675-9421-6fe5e6b78d6e" />
67
+
68
+
64
69
  #### 🔍 Find the Context You Need
65
70
 
66
71
  Search tests, errors, traces, issue references, documentation links, and custom references from the same report.
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "pytest-html-plus"
3
- version = "1.3.0"
4
- description = "Generate Actionable, automatic screenshots, unified Mobile friendly Pytest HTML report in less than 3 seconds — no hooks, merge plugins, no config, xdist-ready."
3
+ version = "1.4.0"
4
+ description = "Self contained pytest HTML reports with automatic screenshots, similar-error detection, shareable test links, and xdist support. No reporting server or complex setup."
5
5
  readme = "README.md"
6
6
  authors = ["reporterplus"]
7
7
  license = "MIT"
@@ -49,6 +49,7 @@ class JSONReporter:
49
49
  screenshots_dir="screenshots",
50
50
  output_dir="report_output",
51
51
  ):
52
+ self.similar_error_groups = {}
52
53
  self.filters = None
53
54
  self.parsed_data = None
54
55
  self.report_path = report_path
@@ -61,6 +62,7 @@ class JSONReporter:
61
62
  with open(self.report_path) as f:
62
63
  data = json.load(f)
63
64
  if isinstance(data, dict) and "results" in data:
65
+ self.similar_error_groups = data.get("similar_error_groups", {})
64
66
  self.results = data["results"]
65
67
  self.filters = data.get("filters", {})
66
68
  elif isinstance(data, list):
@@ -432,19 +434,35 @@ class JSONReporter:
432
434
  }}
433
435
  }}
434
436
 
437
+ function testLinkUrl(anchorId) {{
438
+ const url = new URL(window.location.href);
439
+ url.hash = anchorId;
440
+ return url.href;
441
+ }}
442
+
443
+ async function copyTextToClipboard(text, button, successLabel = null, failureLabel = null) {{
444
+ if (button.dataset.copyPending) return;
445
+ button.dataset.copyPending = 'true';
446
+ const originalContent = button.innerHTML;
447
+ try {{
448
+ await navigator.clipboard.writeText(text);
449
+ if (successLabel) button.textContent = successLabel;
450
+ else button.innerHTML = '<span style="color: #2f7a33;">✓</span>';
451
+ }} catch (error) {{
452
+ console.error("Copy failed:", error);
453
+ if (failureLabel) button.textContent = failureLabel;
454
+ else button.innerHTML = '<span style="color: #d32f2f;">✗</span>';
455
+ }} finally {{
456
+ setTimeout(() => {{
457
+ button.innerHTML = originalContent;
458
+ delete button.dataset.copyPending;
459
+ }}, 1000);
460
+ }}
461
+ }}
462
+
435
463
  function copyTestLink(anchorId, button) {{
436
464
  try {{
437
- const url = new URL(window.location.href);
438
- url.hash = anchorId;
439
- const originalContent = button.innerHTML;
440
- navigator.clipboard.writeText(url.href).then(() => {{
441
- button.innerHTML = '<span style="color: #2f7a33;">✓</span>';
442
- setTimeout(() => {{ button.innerHTML = originalContent; }}, 1000);
443
- }}).catch(err => {{
444
- console.error("Copy link failed:", err);
445
- button.innerHTML = '<span style="color: #d32f2f;">✗</span>';
446
- setTimeout(() => {{ button.innerHTML = originalContent; }}, 1000);
447
- }});
465
+ copyTextToClipboard(testLinkUrl(anchorId), button);
448
466
  }} catch (error) {{
449
467
  console.error("Copy link error:", error);
450
468
  }}
@@ -453,7 +471,7 @@ class JSONReporter:
453
471
  function clearFiltersForDeepLink() {{
454
472
  const filterIds = [
455
473
  'failedOnlyCheckbox', 'errorOnlyCheckbox', 'skippedOnlyCheckbox',
456
- 'longestOnlyCheckbox', 'untrackedOnlyCheckbox', 'flakyOnlyCheckbox'
474
+ 'untrackedOnlyCheckbox', 'flakyOnlyCheckbox'
457
475
  ];
458
476
  filterIds.forEach(id => {{
459
477
  const checkbox = document.getElementById(id);
@@ -519,50 +537,14 @@ class JSONReporter:
519
537
  }}
520
538
 
521
539
 
522
- function toggleFilter(longestCheckbox) {{
523
- const failedCheckbox = document.getElementById('failedOnlyCheckbox');
524
- const skippedCheckbox = document.getElementById('skippedOnlyCheckbox');
525
- const untrackedCheckbox = document.getElementById('untrackedOnlyCheckbox');
526
- const flakyCheckbox = document.getElementById('flakyOnlyCheckbox');
527
- const testsContainer = document.getElementById('tests-container');
528
- const testElements = Array.from(testsContainer.querySelectorAll('.test'));
529
- const errorCheckbox = document.getElementById('errorOnlyCheckbox');
530
-
531
- if (longestCheckbox.checked) {{
532
- failedCheckbox.checked = false;
533
- skippedCheckbox.checked = false;
534
- untrackedCheckbox.checked = false;
535
- flakyCheckbox.checked = false;
536
- errorCheckbox.checked = false;
537
- // Re-enable all tests before sorting
538
- testElements.forEach(el => el.style.display = 'block');
539
-
540
- // Sort and reorder
541
- testElements.sort((a, b) => {{
542
- const aDuration = parseFloat(
543
- a.querySelector('.timestamp').textContent.replace(/[^\\d.]/g, '')
544
- ) || 0;
545
- const bDuration = parseFloat(b.querySelector('.timestamp').textContent.replace(/[^\\d.]/g, '')) || 0;
546
- return bDuration - aDuration;
547
- }});
548
-
549
- testElements.forEach(el => testsContainer.appendChild(el));
550
- }}
551
-
552
- // Reapply marker filter (should now handle failed/skipped too)
553
- filterByMarkers();
554
- }}
555
-
556
540
  function toggleUntrackedOnly(checkbox) {{
557
541
  const testCards = document.querySelectorAll('.test-card');
558
- const longestCheckbox = document.getElementById('longestOnlyCheckbox');
559
542
  const skippedCheckbox = document.getElementById('skippedOnlyCheckbox');
560
543
  const failedCheckbox = document.getElementById('failedOnlyCheckbox');
561
544
  const flakyCheckbox = document.getElementById('flakyOnlyCheckbox');
562
545
  const errorCheckbox = document.getElementById('errorOnlyCheckbox');
563
546
 
564
547
  if (checkbox.checked) {{
565
- longestCheckbox.checked = false;
566
548
  skippedCheckbox.checked = false;
567
549
  failedCheckbox.checked = false;
568
550
  errorCheckbox.checked = false;
@@ -584,14 +566,12 @@ class JSONReporter:
584
566
  }}
585
567
 
586
568
  function toggleFlakyOnly(checkbox) {{
587
- const longestCheckbox = document.getElementById('longestOnlyCheckbox');
588
569
  const untrackedCheckbox = document.getElementById('untrackedOnlyCheckbox');
589
570
  const skippedCheckbox = document.getElementById('skippedOnlyCheckbox');
590
571
  const failedCheckbox = document.getElementById('failedOnlyCheckbox');
591
572
  const errorCheckbox = document.getElementById('errorOnlyCheckbox');
592
573
  const testElements = document.querySelectorAll('.test');
593
574
  if (checkbox.checked) {{
594
- longestCheckbox.checked = false;
595
575
  skippedCheckbox.checked = false;
596
576
  failedCheckbox.checked = false;
597
577
  errorCheckbox.checked = false;
@@ -608,14 +588,12 @@ class JSONReporter:
608
588
  }}
609
589
 
610
590
  function toggleFailedOnly(failedCheckbox) {{
611
- const longestCheckbox = document.getElementById('longestOnlyCheckbox');
612
591
  const untrackedCheckbox = document.getElementById('untrackedOnlyCheckbox');
613
592
  const skippedCheckbox = document.getElementById('skippedOnlyCheckbox');
614
593
  const flakyCheckbox = document.getElementById('flakyOnlyCheckbox');
615
594
  const errorCheckbox = document.getElementById('errorOnlyCheckbox');
616
595
  const testElements = document.querySelectorAll('.test');
617
596
  if (failedCheckbox.checked) {{
618
- longestCheckbox.checked = false;
619
597
  untrackedCheckbox.checked = false;
620
598
  skippedCheckbox.checked = false;
621
599
  flakyCheckbox.checked = false;
@@ -632,7 +610,6 @@ class JSONReporter:
632
610
  }}
633
611
 
634
612
  function toggleSkippedOnly(skippedCheckbox) {{
635
- const longestCheckbox = document.getElementById('longestOnlyCheckbox');
636
613
  const failedCheckbox = document.getElementById('failedOnlyCheckbox');
637
614
  const untrackedCheckbox = document.getElementById('untrackedOnlyCheckbox');
638
615
  const flakyCheckbox = document.getElementById('flakyOnlyCheckbox');
@@ -640,7 +617,6 @@ class JSONReporter:
640
617
  const testElements = document.querySelectorAll('.test');
641
618
 
642
619
  if (skippedCheckbox.checked) {{
643
- longestCheckbox.checked = false;
644
620
  failedCheckbox.checked = false;
645
621
  untrackedCheckbox.checked = false;
646
622
  flakyCheckbox.checked = false;
@@ -658,7 +634,6 @@ class JSONReporter:
658
634
  }}
659
635
 
660
636
  function toggleErrorOnly(errorCheckbox) {{
661
- const longestCheckbox = document.getElementById('longestOnlyCheckbox');
662
637
  const failedCheckbox = document.getElementById('failedOnlyCheckbox');
663
638
  const untrackedCheckbox = document.getElementById('untrackedOnlyCheckbox');
664
639
  const flakyCheckbox = document.getElementById('flakyOnlyCheckbox');
@@ -666,7 +641,6 @@ class JSONReporter:
666
641
  const testElements = document.querySelectorAll('.test');
667
642
 
668
643
  if (errorCheckbox.checked) {{
669
- longestCheckbox.checked = false;
670
644
  failedCheckbox.checked = false;
671
645
  untrackedCheckbox.checked = false;
672
646
  flakyCheckbox.checked = false;
@@ -727,7 +701,6 @@ class JSONReporter:
727
701
 
728
702
  window.onload = function() {{
729
703
  const failedCheckbox = document.getElementById('failedOnlyCheckbox');
730
- const longestCheckbox = document.getElementById('longestOnlyCheckbox');
731
704
  const skippedCheckbox = document.getElementById('skippedOnlyCheckbox');
732
705
  const untrackedCheckbox = document.getElementById('untrackedOnlyCheckbox');
733
706
  const flakyCheckbox = document.getElementById('flakyOnlyCheckbox');
@@ -736,7 +709,6 @@ class JSONReporter:
736
709
  toggleFailedOnly(failedCheckbox);
737
710
  failedCheckbox.addEventListener('change', () => toggleFailedOnly(failedCheckbox));
738
711
  errorCheckbox.addEventListener('change', () => toggleErrorOnly(errorCheckbox));
739
- longestCheckbox.addEventListener('change', () => toggleFilter(longestCheckbox));
740
712
  skippedCheckbox.addEventListener('change', () => toggleSkippedOnly(skippedCheckbox));
741
713
  untrackedCheckbox.addEventListener('change', () => toggleUntrackedOnly(untrackedCheckbox));
742
714
  flakyCheckbox.addEventListener('change', () => toggleFlakyOnly(flakyCheckbox));
@@ -766,23 +738,19 @@ class JSONReporter:
766
738
  <div class="checkbox-container">
767
739
  <label>
768
740
  <input type="checkbox" id="failedOnlyCheckbox" />
769
- Show only failed tests (<span>{self.filters.get("failed", 0)}</span>)
741
+ Failed only (<span>{self.filters.get("failed", 0)}</span>)
770
742
  </label>
771
743
  <label>
772
744
  <input type="checkbox" id="errorOnlyCheckbox" />
773
- Show only error tests (<span>{self.filters.get("error", 0)}</span>)
745
+ Errors only (<span>{self.filters.get("error", 0)}</span>)
774
746
  </label>
775
747
  <label>
776
748
  <input type="checkbox" id="skippedOnlyCheckbox" />
777
- Show only skipped tests (<span>{self.filters.get("skipped", 0)}</span>)
778
- </label>
779
- <label style="margin-left: 1rem;">
780
- <input type="checkbox" id="longestOnlyCheckbox" />
781
- Sort by longest running tests
749
+ Skipped only (<span>{self.filters.get("skipped", 0)}</span>)
782
750
  </label>
783
751
  <label style="margin-left: 1rem;">
784
752
  <input type="checkbox" id="untrackedOnlyCheckbox" />
785
- Show untracked (<span>{self.filters.get("untracked", 0)}</span>)
753
+ Without links (<span>{self.filters.get("untracked", 0)}</span>)
786
754
  </label>
787
755
  <span onclick="toggleUntrackedInfo()" style="
788
756
  display: inline-flex;
@@ -798,7 +766,7 @@ class JSONReporter:
798
766
  font-weight: bold;
799
767
  cursor: pointer;
800
768
  user-select: none;
801
- " title="What is untracked?">i</span>
769
+ " title="What does without links mean?">i</span>
802
770
 
803
771
  <div id="untrackedInfoCard" style="
804
772
  display: none;
@@ -812,7 +780,7 @@ class JSONReporter:
812
780
  font-size: 0.85em;
813
781
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
814
782
  ">
815
- <strong>Untracked Tests:</strong><br>
783
+ <strong>Tests without links:</strong><br>
816
784
  These tests do not have any associated tracking markers like:
817
785
  <ul style="margin: 6px 0 0 16px; padding: 0;">
818
786
  <li><code>pytest.mark.link("https://...")</code></li>
@@ -824,7 +792,7 @@ class JSONReporter:
824
792
  </div>
825
793
  <label style="margin-left: 1rem;">
826
794
  <input type="checkbox" id="flakyOnlyCheckbox">
827
- Show flaky tests only (<span>{self.filters.get("flaky", 0)}</span>)
795
+ Flaky only (<span>{self.filters.get("flaky", 0)}</span>)
828
796
  </label>
829
797
  </div>
830
798
  <div class="search-container">
@@ -1020,6 +988,24 @@ class JSONReporter:
1020
988
  '<span style="display:inline-block; min-width:45px;"></span>'
1021
989
  )
1022
990
 
991
+ similar_badge = ""
992
+ group_id = test.get("similar_error_group")
993
+ group = self.similar_error_groups.get(group_id)
994
+ if group and len(group["nodeids"]) > 1:
995
+ similar_count = len(group["nodeids"]) - 1
996
+ similar_badge = (
997
+ f'<button class="similar-errors-badge" data-group="{escape(group_id, quote=True)}" '
998
+ f'aria-haspopup="dialog" aria-expanded="false" '
999
+ f'title="{similar_count} tests with similar failure messages" '
1000
+ f'aria-label="Show {similar_count} tests with similar failure messages" '
1001
+ f'aria-controls="similar-errors-popover">'
1002
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" '
1003
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
1004
+ 'stroke-linejoin="round" aria-hidden="true" focusable="false">'
1005
+ '<path d="m12 3 10 5-10 5L2 8Z"></path>'
1006
+ '<path d="m2 12 10 5 10-5"></path></svg>'
1007
+ f"<span>{similar_count}</span></button>"
1008
+ )
1023
1009
  escaped_test_name = escape(test["test"])
1024
1010
  test_anchor = stable_test_anchor(test["nodeid"])
1025
1011
  html += f"""
@@ -1041,7 +1027,7 @@ class JSONReporter:
1041
1027
  </svg>
1042
1028
  </button>
1043
1029
  </span>
1044
- <span class="worker-id" style="background: #ddd; border-radius: 3px; padding: 2px 5px; font-size: 0.85em; font-weight: bold;">{test["worker"]}</span>
1030
+ <span class="worker-id" style="background: #ddd; border-radius: 3px; padding: 2px 5px; font-size: 0.85em; font-weight: bold;">{test["worker"]}</span>{similar_badge}
1045
1031
  </div>
1046
1032
 
1047
1033
  <div class="header-section badges-and-timing">
@@ -1104,6 +1090,12 @@ class JSONReporter:
1104
1090
  html = html.replace(
1105
1091
  '<div id="tests-container">', summary_html + '<div id="tests-container">'
1106
1092
  )
1093
+ if self.similar_error_groups:
1094
+ from pytest_html_plus.similar_errors_ui import render_similar_errors
1095
+
1096
+ html += render_similar_errors(
1097
+ self.similar_error_groups, self.results, stable_test_anchor
1098
+ )
1107
1099
  html += "</div></body></html>"
1108
1100
 
1109
1101
  # Save report
@@ -3,10 +3,13 @@ import os
3
3
  from collections import defaultdict
4
4
 
5
5
  from pytest_html_plus.compute_filter_counts import compute_filter_count
6
+ from pytest_html_plus.similar_errors import annotate_similar_errors
6
7
 
7
8
 
8
9
  def merge_json_reports(
9
- directory=".pytest_worker_jsons", output_path="final_report.json"
10
+ directory=".pytest_worker_jsons",
11
+ output_path="final_report.json",
12
+ similar_errors=False,
10
13
  ):
11
14
  all_tests = []
12
15
 
@@ -113,6 +116,9 @@ def merge_json_reports(
113
116
  "results": merged_results,
114
117
  }
115
118
 
119
+ if similar_errors:
120
+ annotate_similar_errors(report_data)
121
+
116
122
  # Write output
117
123
  try:
118
124
  with open(output_path, "w", encoding="utf-8") as f:
@@ -33,7 +33,10 @@ PROFILE_OPTION = "--plus-profile"
33
33
  PROFILE_SECTION = ("tool", "pytest-html-plus", "profiles")
34
34
  OUTPUT_OPTION = "--plus-output"
35
35
  OUTPUT_CHOICES = ("all", "failed-only", "none")
36
+ WORKER_JSON_DIR_OPTION = "--worker-json-dir"
37
+ DEFAULT_WORKER_JSON_DIR = ".pytest_worker_jsons"
36
38
  PROFILE_OPTION_MAP = {
39
+ "similar-errors": {"flag": "--plus-similar-errors", "kind": "explicit-bool"},
37
40
  "json-report": {"flag": "--json-report", "kind": "value"},
38
41
  "capture-screenshots": {"flag": "--capture-screenshots", "kind": "value"},
39
42
  "html-output": {"flag": "--html-output", "kind": "value"},
@@ -46,6 +49,7 @@ PROFILE_OPTION_MAP = {
46
49
  "git-commit": {"flag": "--git-commit", "kind": "value"},
47
50
  "rp-env": {"flag": "--rp-env", "kind": "value"},
48
51
  "output": {"flag": OUTPUT_OPTION, "kind": "value"},
52
+ "worker-json-dir": {"flag": WORKER_JSON_DIR_OPTION, "kind": "value"},
49
53
  }
50
54
 
51
55
 
@@ -125,6 +129,14 @@ def _build_profile_args(profile_name, start_path=None):
125
129
  invalid_keys.append(raw_key)
126
130
  continue
127
131
 
132
+ if option["kind"] == "explicit-bool":
133
+ if not isinstance(value, bool):
134
+ raise pytest.UsageError(
135
+ f"Profile '{profile_name}' option '{raw_key}' must be true or false"
136
+ )
137
+ profile_args.append(f"{option['flag']}={str(value).lower()}")
138
+ continue
139
+
128
140
  if option["kind"] == "bool":
129
141
  if not isinstance(value, bool):
130
142
  raise pytest.UsageError(
@@ -287,6 +299,9 @@ def pytest_sessionfinish(session, exitstatus):
287
299
  html_output = session.config.getoption("--html-output") or "report_output"
288
300
  screenshots_path = session.config.getoption("--screenshots") or "screenshots"
289
301
  raw_xml_report = session.config.getoption("--xml-report")
302
+ worker_dir = (
303
+ session.config.getoption(WORKER_JSON_DIR_OPTION) or DEFAULT_WORKER_JSON_DIR
304
+ )
290
305
 
291
306
  # ---- XML filename validation ----
292
307
  if raw_xml_report:
@@ -320,7 +335,6 @@ def pytest_sessionfinish(session, exitstatus):
320
335
  # ---- Worker behavior ----
321
336
  if is_worker:
322
337
  worker_id = os.getenv("PYTEST_XDIST_WORKER")
323
- worker_dir = ".pytest_worker_jsons"
324
338
  os.makedirs(worker_dir, exist_ok=True)
325
339
 
326
340
  reporter.report_path = os.path.join(worker_dir, f"{worker_id}.json")
@@ -334,8 +348,9 @@ def pytest_sessionfinish(session, exitstatus):
334
348
 
335
349
  # Always run merge (even for single worker)
336
350
  merge_json_reports(
337
- directory=".pytest_worker_jsons" if is_xdist else html_output,
351
+ directory=worker_dir if is_xdist else html_output,
338
352
  output_path=json_path,
353
+ similar_errors=resolve_similar_errors(session.config),
339
354
  )
340
355
 
341
356
  script_path = os.path.join(os.path.dirname(__file__), "generate_html_report.py")
@@ -428,9 +443,34 @@ def pytest_load_initial_conftests(args):
428
443
  args.append("--capture=tee-sys")
429
444
 
430
445
 
446
+ def resolve_similar_errors(config):
447
+ explicit = config.getoption("--plus-similar-errors", default=None)
448
+ if explicit is not None:
449
+ return explicit == "true"
450
+ path = _find_pyproject_toml(getattr(config, "rootpath", None))
451
+ if path is None:
452
+ return True
453
+ with path.open("rb") as stream:
454
+ value = (
455
+ tomllib.load(stream)
456
+ .get("tool", {})
457
+ .get("pytest-html-plus", {})
458
+ .get("similar_errors", True)
459
+ )
460
+ if not isinstance(value, bool):
461
+ raise pytest.UsageError("similar_errors must be a TOML boolean")
462
+ return value
463
+
464
+
431
465
  def pytest_addoption(parser):
432
466
  group = parser.getgroup("pytest-html-plus", "pytest-html-plus reporting options")
433
467
 
468
+ group.addoption(
469
+ "--plus-similar-errors",
470
+ choices=["true", "false"],
471
+ default=None,
472
+ help="Find similar final errors (default: true; use false to disable)",
473
+ )
434
474
  group.addoption(
435
475
  PROFILE_OPTION,
436
476
  action="store",
@@ -462,6 +502,15 @@ def pytest_addoption(parser):
462
502
  )
463
503
  group.addoption("--html-output", default="report_output")
464
504
  group.addoption("--screenshots", default="screenshots")
505
+ group.addoption(
506
+ WORKER_JSON_DIR_OPTION,
507
+ action="store",
508
+ default=DEFAULT_WORKER_JSON_DIR,
509
+ help=(
510
+ "Directory where pytest-xdist workers write their partial JSON "
511
+ "reports before merging (default: .pytest_worker_jsons)"
512
+ ),
513
+ )
465
514
  group.addoption(
466
515
  "--plus-email",
467
516
  action="store_true",
@@ -523,7 +572,9 @@ def pytest_configure(config):
523
572
  global _saved_config
524
573
  _saved_config = config
525
574
 
526
- INTERNAL_JSON_DIR = Path(".pytest_worker_jsons")
575
+ INTERNAL_JSON_DIR = Path(
576
+ config.getoption(WORKER_JSON_DIR_OPTION) or DEFAULT_WORKER_JSON_DIR
577
+ )
527
578
  report_path = config.getoption("--json-report") or "final_report.json"
528
579
  worker_id = os.getenv("PYTEST_XDIST_WORKER")
529
580
 
@@ -0,0 +1,72 @@
1
+ """Conservative, linear-time matching of final errors in a consolidated report."""
2
+
3
+ import re
4
+ from collections import defaultdict
5
+
6
+ _ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
7
+ _EXCEPTION = re.compile(r"^([\w.]*(?:Error|Exception|Failure)):\s*(.*)$")
8
+ _ADDRESS = re.compile(r"(\bobject at )0x[0-9a-fA-F]+\b")
9
+
10
+
11
+ def error_fingerprint(error):
12
+ """Keep exception identity and message; never hash a traceback or type alone."""
13
+ if not isinstance(error, str):
14
+ return None
15
+ lines = [
16
+ re.sub(r"^E\s+", "", line.strip()) for line in _ANSI.sub("", error).splitlines()
17
+ ]
18
+ for index, line in enumerate(lines):
19
+ match = _EXCEPTION.match(line)
20
+ if not match:
21
+ continue
22
+ kind, message = match.groups()
23
+ # Preserve multiline message differences, stopping at diagnostic sections.
24
+ continuation = []
25
+ for following in lines[index + 1 :]:
26
+ if following.startswith(
27
+ ("Call log:", "Traceback ", "During handling", "The above exception")
28
+ ):
29
+ break
30
+ continuation.append(following)
31
+ message = "\n".join([message, *continuation]).strip()
32
+ # Preserve paths: worker directories and filenames may be meaningful.
33
+ message = " ".join(_ADDRESS.sub(r"\1<address>", message).split())
34
+ if len(re.findall(r"[A-Za-z]{2,}", message)) < 2:
35
+ return None
36
+ if kind.rsplit(".", 1)[-1] == "TimeoutError" and message.casefold().rstrip(
37
+ "."
38
+ ) in {
39
+ "timed out",
40
+ "operation timed out",
41
+ "timeout exceeded",
42
+ }:
43
+ return None
44
+ if message in {"assert False", "assert True", "assert 0", "assert 1"}:
45
+ return None
46
+ return kind, message
47
+ return None
48
+
49
+
50
+ def annotate_similar_errors(report):
51
+ """Annotate consolidated results in place, storing membership only once.
52
+
53
+ Call only when enabled. Distinct nodeids are test identities; attempts and
54
+ traces are deliberately not inspected. Can also be used by JSON-only callers.
55
+ """
56
+ groups = defaultdict(dict)
57
+ for test in report["results"]:
58
+ test.pop("similar_error_group", None)
59
+ if test.get("status") not in {"failed", "error"} or not test.get("nodeid"):
60
+ continue
61
+ fingerprint = error_fingerprint(test.get("error"))
62
+ if fingerprint:
63
+ groups[fingerprint][test["nodeid"]] = test
64
+ metadata = {}
65
+ for fingerprint, members in groups.items():
66
+ if len(members) < 2:
67
+ continue
68
+ group_id = f"similar-{len(metadata) + 1}"
69
+ metadata[group_id] = {"message": fingerprint[1], "nodeids": list(members)}
70
+ for test in members.values():
71
+ test["similar_error_group"] = group_id
72
+ report["similar_error_groups"] = metadata
@@ -0,0 +1,140 @@
1
+ """Optional report UI; group membership is embedded once and rendered on demand."""
2
+
3
+ import json
4
+
5
+
6
+ def render_similar_errors(groups, results, anchor_for_nodeid):
7
+ names = {t["nodeid"]: t["test"] for t in results}
8
+ payload = {
9
+ key: {
10
+ "message": group["message"],
11
+ "tests": [
12
+ {"anchor": anchor_for_nodeid(nodeid), "name": names[nodeid]}
13
+ for nodeid in group["nodeids"]
14
+ if nodeid in names
15
+ ],
16
+ }
17
+ for key, group in groups.items()
18
+ }
19
+ data = json.dumps(payload).replace("<", "\\u003c").replace("&", "\\u0026")
20
+ return (
21
+ '<script type="application/json" id="similar-errors-data">'
22
+ + data
23
+ + "</script>"
24
+ + UI
25
+ )
26
+
27
+
28
+ UI = r"""
29
+ <style>
30
+ .similar-errors-badge { background:transparent; color:#444; border:1px solid #bbb;
31
+ border-radius:3px; padding:2px 5px; font-size:.85em; cursor:pointer;
32
+ display:inline-flex; align-items:center; gap:4px; vertical-align:middle; }
33
+ .similar-errors-badge svg { flex-shrink:0; }
34
+ #similar-errors-popover { position:fixed; inset:auto; margin:0; z-index:1000;
35
+ box-sizing:border-box; width:min(360px, calc(100vw - 16px)); max-height:50vh;
36
+ overflow:auto; background:white; color:#333; border:1px solid #bbb;
37
+ border-radius:5px; padding:12px; box-shadow:0 3px 12px #0003; }
38
+ #similar-errors-popover .similar-errors-heading { display:flex; align-items:center; gap:8px; }
39
+ #similar-errors-popover .similar-errors-heading strong { flex:1; min-width:0; }
40
+ #similar-errors-popover .copy-all-links { display:inline-flex; align-items:center;
41
+ gap:4px; margin:0; padding:4px; flex-shrink:0; white-space:nowrap; }
42
+ #similar-errors-popover a { display:block; padding:5px 0; overflow-wrap:anywhere; }
43
+ #similar-errors-popover p { overflow-wrap:anywhere; }
44
+ #similar-errors-popover .similar-errors-note { margin-top:10px; padding-top:8px;
45
+ border-top:1px solid #e5e7eb; color:#666; font-size:.8rem; line-height:1.5; }
46
+ #similar-errors-popover .similar-errors-note code { overflow-wrap:anywhere; }
47
+ </style>
48
+ <div id="similar-errors-popover" role="dialog" aria-label="Similar errors in this report" hidden></div>
49
+ <script>
50
+ (() => {
51
+ const groups = JSON.parse(document.getElementById('similar-errors-data').textContent);
52
+ const panel = document.getElementById('similar-errors-popover');
53
+ let trigger = null;
54
+ function close(restoreFocus = false) {
55
+ panel.hidden = true;
56
+ if (trigger) {
57
+ trigger.setAttribute('aria-expanded', 'false');
58
+ if (restoreFocus) trigger.focus();
59
+ }
60
+ trigger = null;
61
+ }
62
+ document.querySelectorAll('.similar-errors-badge').forEach(button => {
63
+ button.addEventListener('click', event => {
64
+ event.stopPropagation();
65
+ const same = trigger === button;
66
+ close();
67
+ if (same) return;
68
+ trigger = button;
69
+ button.setAttribute('aria-expanded', 'true');
70
+ const group = groups[button.dataset.group];
71
+ panel.replaceChildren();
72
+ const title = document.createElement('strong');
73
+ title.textContent = 'Similar errors in this report';
74
+ const heading = document.createElement('div');
75
+ heading.className = 'similar-errors-heading';
76
+ const copyButton = document.createElement('button');
77
+ copyButton.type = 'button';
78
+ copyButton.className = 'inline-copy-btn copy-all-links';
79
+ copyButton.setAttribute('aria-live', 'polite');
80
+ copyButton.title = 'Copy links to every test in this group, including this test';
81
+ const icon = button.closest('.test').querySelector('.test-link-btn svg');
82
+ if (icon) {
83
+ const copyIcon = icon.cloneNode(true);
84
+ copyIcon.setAttribute('aria-hidden', 'true');
85
+ copyButton.append(copyIcon);
86
+ }
87
+ copyButton.setAttribute('aria-label', 'Copy all test links');
88
+ copyButton.addEventListener('click', event => {
89
+ event.stopPropagation();
90
+ const seen = new Set();
91
+ const text = group.tests.filter(test => {
92
+ if (seen.has(test.anchor)) return false;
93
+ seen.add(test.anchor);
94
+ return true;
95
+ }).map(test => `${test.name.replace(/[\r\n]+/g, ' ')} — ${testLinkUrl(test.anchor)}`).join('\n');
96
+ copyTextToClipboard(text, copyButton);
97
+ });
98
+ heading.append(title, copyButton);
99
+ const message = document.createElement('p');
100
+ message.textContent = group.message;
101
+ panel.append(heading, message);
102
+ group.tests.forEach(test => {
103
+ if (test.anchor === button.closest('.test').id) return;
104
+ const link = document.createElement('a');
105
+ link.href = '#' + test.anchor;
106
+ link.textContent = test.name;
107
+ link.addEventListener('click', () => {
108
+ close();
109
+ if (location.hash === link.hash) revealTestFromFragment();
110
+ });
111
+ panel.append(link);
112
+ });
113
+ const note = document.createElement('div');
114
+ note.className = 'similar-errors-note';
115
+ note.append('Similar messages may have different causes.', document.createElement('br'), 'Disable: ');
116
+ const command = document.createElement('code');
117
+ command.textContent = '--plus-similar-errors=false';
118
+ note.append(command, document.createElement('br'), "Recipients need access to this report. Local file links aren't shareable.");
119
+ panel.append(note);
120
+ panel.hidden = false;
121
+ const rect = button.getBoundingClientRect();
122
+ panel.style.left = Math.max(8, Math.min(rect.left, innerWidth - panel.offsetWidth - 8)) + 'px';
123
+ panel.style.top = Math.max(8, Math.min(rect.bottom + 4, innerHeight - panel.offsetHeight - 8)) + 'px';
124
+ panel.querySelector('a')?.focus();
125
+ });
126
+ });
127
+ document.addEventListener('click', event => {
128
+ if (!panel.contains(event.target)) close();
129
+ });
130
+ document.addEventListener('keydown', event => {
131
+ if (event.key === 'Escape' && !panel.hidden) { event.preventDefault(); close(true); }
132
+ });
133
+ document.addEventListener('focusin', event => {
134
+ if (!panel.hidden && !panel.contains(event.target) && event.target !== trigger) close();
135
+ });
136
+ window.addEventListener('hashchange', () => close());
137
+ window.addEventListener('resize', () => close());
138
+ })();
139
+ </script>
140
+ """