aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKirill Rysin <35688753+naspirato@users.noreply.github.com>2024-07-31 00:35:24 +0200
committerGitHub <noreply@github.com>2024-07-31 01:35:24 +0300
commit6a2c5ffe325beeac53172495ffcc5f8191841fe8 (patch)
tree26bcac7c2ed45bdf4a159283d93f78385e098a4f
parent2d9e5a2434434b113f0bca8aee5b5fc1dd01e0ab (diff)
downloadydb-6a2c5ffe325beeac53172495ffcc5f8191841fe8.tar.gz
History in test results (#7213)
-rwxr-xr-x.github/scripts/tests/generate-summary.py42
-rw-r--r--.github/scripts/tests/get_test_history.py93
-rw-r--r--.github/scripts/tests/templates/summary.html299
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 %}