diff options
author | Kirill Rysin <35688753+naspirato@users.noreply.github.com> | 2024-07-31 00:35:24 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-07-31 01:35:24 +0300 |
commit | 6a2c5ffe325beeac53172495ffcc5f8191841fe8 (patch) | |
tree | 26bcac7c2ed45bdf4a159283d93f78385e098a4f | |
parent | 2d9e5a2434434b113f0bca8aee5b5fc1dd01e0ab (diff) | |
download | ydb-6a2c5ffe325beeac53172495ffcc5f8191841fe8.tar.gz |
History in test results (#7213)
-rwxr-xr-x | .github/scripts/tests/generate-summary.py | 42 | ||||
-rw-r--r-- | .github/scripts/tests/get_test_history.py | 93 | ||||
-rw-r--r-- | .github/scripts/tests/templates/summary.html | 299 |
3 files changed, 392 insertions, 42 deletions
diff --git a/.github/scripts/tests/generate-summary.py b/.github/scripts/tests/generate-summary.py index ba4eccdcd7..751faa33d8 100755 --- a/.github/scripts/tests/generate-summary.py +++ b/.github/scripts/tests/generate-summary.py @@ -14,6 +14,7 @@ from typing import List, Optional, Dict from jinja2 import Environment, FileSystemLoader, StrictUndefined from junit_utils import get_property_value, iter_xml_files from gh_status import update_pr_comment_text +from get_test_history import get_test_history class TestStatus(Enum): @@ -38,6 +39,7 @@ class TestResult: status: TestStatus log_urls: Dict[str, str] elapsed: float + count_of_passed: int @property def status_display(self): @@ -97,7 +99,7 @@ class TestResult: elapsed = 0 print(f"Unable to cast elapsed time for {classname}::{name} value={elapsed!r}") - return cls(classname, name, status, log_urls, elapsed) + return cls(classname, name, status, log_urls, elapsed, 0) class TestSummaryLine: @@ -222,12 +224,13 @@ def render_pm(value, url, diff=None): return text -def render_testlist_html(rows, fn): +def render_testlist_html(rows, fn, build_preset): TEMPLATES_PATH = os.path.join(os.path.dirname(__file__), "templates") env = Environment(loader=FileSystemLoader(TEMPLATES_PATH), undefined=StrictUndefined) status_test = {} + last_n_runs = 5 has_any_log = set() for t in rows: @@ -243,8 +246,35 @@ def render_testlist_html(rows, fn): # remove status group without tests status_order = [s for s in status_order if s in status_test] + # get failed tests + failed_tests_array = [] + history={} + for test in status_test.get(TestStatus.FAIL, []): + failed_tests_array.append(test.full_name) + + if failed_tests_array: + try: + history = get_test_history(failed_tests_array, last_n_runs, build_preset) + except Exception as e: + print(f'Error:{e}') + + # sorting, at first show tests with passed resuts in history + + if TestStatus.FAIL in status_test: + for test in status_test.get(TestStatus.FAIL, []): + if test.full_name in history: + test.count_of_passed = history[test.full_name][ + next(iter(history[test.full_name])) + ]["count_of_passed"] + else: + test.count_of_passed = 0 + status_test[TestStatus.FAIL].sort(key=lambda val: (val.count_of_passed, val.full_name), reverse=True) + content = env.get_template("summary.html").render( - status_order=status_order, tests=status_test, has_any_log=has_any_log + status_order=status_order, + tests=status_test, + has_any_log=has_any_log, + history=history, ) with open(fn, "w") as fp: @@ -267,7 +297,7 @@ def write_summary(summary: TestSummary): fp.close() -def gen_summary(public_dir, public_dir_url, paths, is_retry: bool): +def gen_summary(public_dir, public_dir_url, paths, is_retry: bool, build_preset): summary = TestSummary(is_retry=is_retry) for title, html_fn, path in paths: @@ -281,7 +311,7 @@ def gen_summary(public_dir, public_dir_url, paths, is_retry: bool): html_fn = os.path.relpath(html_fn, public_dir) report_url = f"{public_dir_url}/{html_fn}" - render_testlist_html(summary_line.tests, os.path.join(public_dir, html_fn)) + render_testlist_html(summary_line.tests, os.path.join(public_dir, html_fn),build_preset) summary_line.add_report(html_fn, report_url) summary.add_line(summary_line) @@ -349,7 +379,7 @@ def main(): paths = iter(args.args) title_path = list(zip(paths, paths, paths)) - summary = gen_summary(args.public_dir, args.public_dir_url, title_path, is_retry=bool(args.is_retry)) + summary = gen_summary(args.public_dir, args.public_dir_url, title_path, is_retry=bool(args.is_retry),build_preset=args.build_preset) write_summary(summary) if summary.is_failed: diff --git a/.github/scripts/tests/get_test_history.py b/.github/scripts/tests/get_test_history.py new file mode 100644 index 0000000000..03f625e2b7 --- /dev/null +++ b/.github/scripts/tests/get_test_history.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +import configparser +import os +import ydb +import datetime + + +dir = os.path.dirname(__file__) +config = configparser.ConfigParser() +config_file_path = f"{dir}/../../config/ydb_qa_db.ini" +config.read(config_file_path) + +DATABASE_ENDPOINT = config["QA_DB"]["DATABASE_ENDPOINT"] +DATABASE_PATH = config["QA_DB"]["DATABASE_PATH"] + + +def get_test_history(test_names_array, last_n_runs_of_test_amount, build_type): + if "CI_YDB_SERVICE_ACCOUNT_KEY_FILE_CREDENTIALS" not in os.environ: + print( + "Error: Env variable CI_YDB_SERVICE_ACCOUNT_KEY_FILE_CREDENTIALS is missing, skipping" + ) + return {} + else: + # Do not set up 'real' variable from gh workflows because it interfere with ydb tests + # So, set up it locally + os.environ["YDB_SERVICE_ACCOUNT_KEY_FILE_CREDENTIALS"] = os.environ[ + "CI_YDB_SERVICE_ACCOUNT_KEY_FILE_CREDENTIALS" + ] + + query = f""" + PRAGMA AnsiInForEmptyOrNullableItemsCollections; + DECLARE $test_names AS List<Utf8>; + DECLARE $rn_max AS Int32; + DECLARE $build_type AS Utf8; + + $tests=( + SELECT + suite_folder ||'/' || test_name as full_name,test_name,build_type, commit, branch, run_timestamp, status, status_description, + ROW_NUMBER() OVER (PARTITION BY test_name ORDER BY run_timestamp DESC) AS rn + FROM + `test_results/test_runs_results` + where (job_name ='Nightly-run' or job_name like 'Postcommit%') and + build_type = $build_type and + suite_folder ||'/' || test_name in $test_names + and status != 'skipped' + ); + + select full_name,test_name,build_type, commit, branch, run_timestamp, status, status_description,rn, + COUNT_IF(status = 'passed') over (PARTITION BY test_name) as count_of_passed + from $tests + WHERE rn <= $rn_max + ORDER BY test_name, run_timestamp; + """ + + with ydb.Driver( + endpoint=DATABASE_ENDPOINT, + database=DATABASE_PATH, + credentials=ydb.credentials_from_env_variables(), + ) as driver: + driver.wait(timeout=10, fail_fast=True) + session = ydb.retry_operation_sync( + lambda: driver.table_client.session().create() + ) + + with session.transaction() as transaction: + prepared_query = session.prepare(query) + query_params = { + "$test_names": test_names_array, + "$rn_max": last_n_runs_of_test_amount, + "$build_type": build_type, + } + + result_set = session.transaction(ydb.SerializableReadWrite()).execute( + prepared_query, parameters=query_params, commit_tx=True + ) + + results = {} + for row in result_set[0].rows: + if not row["full_name"].decode("utf-8") in results: + results[row["full_name"].decode("utf-8")] = {} + + results[row["full_name"].decode("utf-8")][row["run_timestamp"]] = { + "status": row["status"], + "commit": row["commit"], + "datetime": datetime.datetime.fromtimestamp(int(row["run_timestamp"] / 1000000)).strftime("%H:%m %B %d %Y"), + "count_of_passed": row["count_of_passed"], + } + return results + + +if __name__ == "__main__": + get_test_history(test_names_array, last_n_runs_of_test_amount, build_type) diff --git a/.github/scripts/tests/templates/summary.html b/.github/scripts/tests/templates/summary.html index d0daca5292..c069a5616a 100644 --- a/.github/scripts/tests/templates/summary.html +++ b/.github/scripts/tests/templates/summary.html @@ -1,16 +1,39 @@ <html> <head> <style> + body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + box-sizing: border-box; + width: 100%; + } + h1 { + font-size: 20px; + } th { text-transform: uppercase; } th, td { padding: 5px; + word-break: break-word; /* Allow breaking long words */ + font-size: 14px; + min-width: 80px; + } + td.test_name{ + min-width: 79vw; + max-width: 79vw; + } + td.test_name.with_history{ + min-width: 73vw; + max-width: 73vw; } table { border-collapse: collapse; + width: 100%; + left: 5; } span.test_status { @@ -28,64 +51,226 @@ span.test_mute { color: blue; } + .svg_passed { + fill: green; + } + + .svg_failure { + fill: red; + } + + .svg_skipped { + fill: gray; + } + + .svg_mute { + fill: blue; + } + + .svg-icon { + float: left; + width: 16px; + height: 16px; + margin-right: 0px; + border-radius: 50%; + position: relative; /* Essential for tooltips */ + cursor: pointer; + } + + .tooltip { + visibility: hidden; + width: 180px; + background-color: #7d7d92; + color: #fff; + text-align: left; + border-radius: 5px; + padding: 5px; + position: absolute; + z-index: 3; + bottom: 100%; /* Positioned above the svg icon */ + left: 50%; /* Center the tooltip */ + margin-left: -95px; /* Use negative margin to actually center the tooltip */ + opacity: 0; + transition: opacity 0.3s; + overflow: hidden; + white-space: nowrap; /* Don't forget this one */ + text-overflow: ellipsis; + } + + .tooltip::after { + content: ""; + position: absolute; + top: 100%; /* Arrow will be positioned at the bottom of the tooltip */ + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #7d7d92 transparent transparent transparent; /* Arrow color */ + } + + .tooltip.visible { + visibility: visible; + opacity: 1; + } + button.copy { float: right; + position: relative; /* To hold the copied tooltip */ } table > tbody > tr > td:nth-child(2), table > tbody > tr > td:nth-child(3), table > tbody > tr > td:nth-child(4) { - text-align: center; + text-align: left; + } + + .collapsible-content { + display: table-row-group; + position: absolute; + top: -9999px; + left: -9999px; + height: 0; + overflow: hidden; + } + + .collapsible-content.active { + position: relative; + top: 0; + left: 5; + height: auto; + } + + .collapsible-header { + cursor: pointer; + background-color: #f2f2f2; + padding: 10px; + border: 1px solid #ddd; + margin-bottom: 5px; + } + + + .toggle-visibility-buttons { + margin-bottom: 10px; + overflow:hidden; + float: right; } </style> - <script> - function copyTestNameToClipboard(text) { - const full_name = text.trim(); - const pieces = /(.+)\/([^$]+)$/.exec(full_name); - - if (!pieces) { - console.error("Unable to split path/test for %o", full_name); - return; - } - let [path, testName] = [pieces[1], pieces[2]]; +<script> + function copyTestNameToClipboard(text) { + const full_name = text.trim(); + const pieces = /(.+)\/([^$]+)$/.exec(full_name); - const namePieces = testName.split('.'); + if (!pieces) { + console.error("Unable to split path/test name from %o", full_name); + return; + } + let [path, testName] = [pieces[1], pieces[2]]; - if (namePieces.length === 2) { - testName = namePieces[0] + '::' + namePieces[1]; - } else { - testName = namePieces[0] + '.' + namePieces[1] + '::' + namePieces.slice(2).join('::'); + const namePieces = testName.split('.'); + + if (namePieces.length === 2) { + testName = namePieces[0] + '::' + namePieces[1]; + } else { + testName = namePieces[0] + '.' + namePieces[1] + '::' + namePieces.slice(2).join('::'); + } + + const cmdArg = `${path} -F '${testName}'`; + + console.log(cmdArg); + + navigator.clipboard.writeText(cmdArg).then( + () => { + console.log("Copied!"); + showCopiedTooltip(); + }, + () => { + console.error("Unable to copy %o to clipboard", cmdArg); } + ); + } - const cmdArg = `${path} -F '${testName}'`; + let lastOpenedTooltip = null; - console.log(cmdArg); + function toggleTooltip(event) { + event.stopPropagation(); + const tooltip = event.currentTarget.querySelector('.tooltip'); - navigator.clipboard.writeText(cmdArg).catch( - () => { - console.error("Unable to copy %o to clipboard", cmdArg); - } - ); + if (tooltip.classList.contains('visible')) { + tooltip.classList.remove('visible'); + lastOpenedTooltip = null; + } else { + hideTooltips(); + tooltip.classList.add('visible'); + lastOpenedTooltip = tooltip; } - function copyOnPress(event) { - event.preventDefault(); - copyTestNameToClipboard(event.target.previousElementSibling.textContent) + } + + function hideTooltips() { + if (lastOpenedTooltip) { + lastOpenedTooltip.classList.remove('visible'); + lastOpenedTooltip = null; } + } + - document.addEventListener("DOMContentLoaded", function() { - const els = document.getElementsByClassName("copy"); - for (let i = 0; i < els.length; i++) { - els[i].addEventListener('click', copyOnPress); + function toggleAllTables(action) { + const contents = document.querySelectorAll('.collapsible-content'); + if (action === 'expand') { + contents.forEach(content => content.classList.add('active')); + } else if (action === 'collapse') { + contents.forEach(content => content.classList.remove('active')); + } + } + + document.addEventListener("DOMContentLoaded", function() { + document.addEventListener('click', function(event) { + if (!event.target.closest('.svg-icon') && !event.target.classList.contains('copy')) { + hideTooltips(); } }); - </script> + const svgIcons = document.querySelectorAll('.svg-icon'); + svgIcons.forEach(icon => { + icon.addEventListener('click', toggleTooltip); + }); + const copyButtons = document.querySelectorAll(".copy"); + copyButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + copyTestNameToClipboard(event.target.closest('.copy').previousElementSibling.textContent); + }); + }); + const headers = document.querySelectorAll(".collapsible-header"); + headers.forEach(header => { + header.addEventListener('click', function() { + const content = this.nextElementSibling; + if (content.classList.contains('active')) { + content.classList.remove('active'); + } else { + content.classList.add('active'); + } + }); + }); + + document.getElementById('expand-all').addEventListener('click', () => toggleAllTables('expand')); + document.getElementById('collapse-all').addEventListener('click', () => toggleAllTables('collapse')); + }); +</script> </head> <body> + <div class="toggle-visibility-buttons"> + <button id="expand-all">Expand All for Search</button> + <button id="collapse-all">Collapse All</button> + </div> {% for status in status_order %} -<h1 id="{{ status.name}}">{{ status.name }} ({{ tests[status] | length }})</h1> -<table style="width:100%;" border="1"> +<h1 id="{{ status.name}}" class="collapsible-header">{{ status.name }} ({{ tests[status] | length }})</h1> +<table class="collapsible-content{% if status.name == 'FAIL' %} active{% endif %}" border="1"> <thead> <tr> <th>test name</th> + {% if status.name == 'FAIL' and history%} + <th>history<br> + old->new + </th> + {% endif %} <th>elapsed</th> <th>status</th> {% if status in has_any_log %} @@ -96,10 +281,52 @@ <tbody> {% for t in tests[status] %} <tr> - <td> + {% if status.name == 'FAIL' %} + <td class="test_name with_history"> + {% else %} + <td class="test_name"> + {% endif %} <span>{{ t.full_name }}</span> - {% if status.is_error %}<button class="copy" title="Copy test filter to clipboard">Copy test filter</button>{% endif %} + {% if status.is_error %} + <button class="copy" title="Copy test filter to clipboard" > + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14" height="14" class="g-icon" fill="currentColor" stroke="none" aria-hidden="true"> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"> + <path class="copy_svg" fill="currentColor" fill-rule="evenodd" d="M12 2.5H8A1.5 1.5 0 0 0 6.5 4v1H8a3 3 0 0 1 3 3v1.5h1A1.5 1.5 0 0 0 13.5 8V4A1.5 1.5 0 0 0 12 2.5M11 11h1a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H8a3 3 0 0 0-3 3v1H4a3 3 0 0 0-3 3v4a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3zM4 6.5h4A1.5 1.5 0 0 1 9.5 8v4A1.5 1.5 0 0 1 8 13.5H4A1.5 1.5 0 0 1 2.5 12V8A1.5 1.5 0 0 1 4 6.5" clip-rule="evenodd"></path> + </svg> + </svg> + </button> + {% endif %} </td> + {% if (status.name == 'FAIL' and t.full_name in history) %} + <td> + {% for h in history[t.full_name] %} + <span class="svg-icon"> + {% if history[t.full_name][h].status == 'failure' %} + <svg class="svg_failure" viewBox="0 0 16 16" > + <path fill-rule="evenodd" d="M13.5 8a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0M6.53 5.47a.75.75 0 0 0-1.06 1.06L6.94 8 5.47 9.47a.75.75 0 1 0 1.06 1.06L8 9.06l1.47 1.47a.75.75 0 1 0 1.06-1.06L9.06 8l1.47-1.47a.75.75 0 1 0-1.06-1.06L8 6.94z" clip-rule="evenodd"></path> + </svg> + {% elif history[t.full_name][h].status == 'passed' %} + <svg class="svg_passed" viewBox="0 0 16 16" > + <path fill-rule="evenodd" d="M13.5 8a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0m-3.9-1.55a.75.75 0 1 0-1.2-.9L7.419 8.858 6.03 7.47a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.13-.08z" clip-rule="evenodd"></path> + </svg> + {% elif history[t.full_name][h].status == 'mute' %} + <svg class="svg_mute" viewBox="0 0 16 16" > + <path fill-rule="evenodd" d="M5.06 9.94A1.5 1.5 0 0 0 4 9.5H2a.5.5 0 0 1-.5-.5V7a.5.5 0 0 1 .5-.5h2a1.5 1.5 0 0 0 1.06-.44l2.483-2.482a.268.268 0 0 1 .457.19v8.464a.268.268 0 0 1-.457.19zM2 5h2l2.482-2.482A1.768 1.768 0 0 1 9.5 3.768v8.464a1.768 1.768 0 0 1-3.018 1.25L4 11H2a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2m10.28.72a.75.75 0 1 0-1.06 1.06L12.44 8l-1.22 1.22a.75.75 0 1 0 1.06 1.06l1.22-1.22 1.22 1.22a.75.75 0 1 0 1.06-1.06L14.56 8l1.22-1.22a.75.75 0 0 0-1.06-1.06L13.5 6.94z" clip-rule="evenodd"></path> </svg> + </svg> + {% endif %} + <span class="tooltip"> + Status: {{history[t.full_name][h].status}}<br> + Date: {{ history[t.full_name][h].datetime }}<br> + SHA: <a href="https://github.com/ydb-platform/ydb//commit/{{ history[t.full_name][h].commit }}" style="color: #00f;" target="_blank">{{history[t.full_name][h].commit}}</a> + </span> + + </span> + {% endfor %} + </td> + {% elif (status.name == 'FAIL' and history) %} + <td></td> + {% elif (status.name == 'FAIL') %} + {% endif %} <td><span title="{{ t.elapsed }}s">{{ t.elapsed_display }}</span></td> <td> <span class="test_status test_{{ t.status_display }}">{{ t.status_display }}</span> @@ -108,7 +335,7 @@ <td> {% if t.log_urls %} {% for log_name, log_url in t.log_urls.items() %} - <a href="{{ log_url }}">{{ log_name.upper() }}</a>{% if not loop.last %} | {% endif %} + <a href="{{ log_url }}">{{ log_name }}</a>{% if not loop.last %} | {% endif %} {% endfor %} {% else %} |