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.
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/PKG-INFO +11 -6
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/README.md +9 -4
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pyproject.toml +2 -2
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/generate_html_report.py +62 -70
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/json_merge.py +7 -1
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/plugin.py +54 -3
- pytest_html_plus-1.4.0/pytest_html_plus/similar_errors.py +72 -0
- pytest_html_plus-1.4.0/pytest_html_plus/similar_errors_ui.py +140 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/LICENSE +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/__init__.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/compute_filter_counts.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/compute_report_metadata.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/extract_link.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/json_to_xml_converter.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/resolver_driver.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/send_email_report.py +0 -0
- {pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/utils.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-html-plus
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary:
|
|
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
|
-
|
|
35
|
+
**Find the failure. Spot the pattern. Debug together.**
|
|
36
36
|
|
|
37
|
-
|
|
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
|
-
|
|
1
|
+
**Find the failure. Spot the pattern. Debug together.**
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
description = "
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
741
|
+
Failed only (<span>{self.filters.get("failed", 0)}</span>)
|
|
770
742
|
</label>
|
|
771
743
|
<label>
|
|
772
744
|
<input type="checkbox" id="errorOnlyCheckbox" />
|
|
773
|
-
|
|
745
|
+
Errors only (<span>{self.filters.get("error", 0)}</span>)
|
|
774
746
|
</label>
|
|
775
747
|
<label>
|
|
776
748
|
<input type="checkbox" id="skippedOnlyCheckbox" />
|
|
777
|
-
|
|
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
|
-
|
|
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
|
|
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>
|
|
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
|
-
|
|
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",
|
|
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=
|
|
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(
|
|
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
|
+
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_html_plus-1.3.0 → pytest_html_plus-1.4.0}/pytest_html_plus/compute_report_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|