mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 04:16:38 +00:00
334 lines
13 KiB
Python
Executable File
334 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright 2024 RustFS Team
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Diff a ceph/s3-tests junit.xml result against the RustFS test classification lists.
|
|
|
|
Classifies every executed test into:
|
|
- regressions: listed in implemented_tests.txt but failed/errored
|
|
- promotion candidates: passed but listed in unimplemented_tests.txt / excluded_tests.txt
|
|
- unclassified passes: passed but not present in any list (new upstream tests)
|
|
- unclassified failures: failed and not present in any list (new upstream tests)
|
|
|
|
Writes a markdown report and prints a summary to stdout. Optional gates reject
|
|
regressions, unclassified tests, stale classifications, and incomplete node-ID
|
|
execution.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import Counter
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
LIST_FILES = {
|
|
"implemented": "implemented_tests.txt",
|
|
"unimplemented": "unimplemented_tests.txt",
|
|
"excluded": "excluded_tests.txt",
|
|
# Lifecycle behavior lane (backlog#1148 ilm-10): gated by its own ci.yml job
|
|
# against a debug-accelerated server. In the full `scope=all` sweep these run
|
|
# against the plain server (no scanner / no RUSTFS_ILM_DEBUG_DAY_SECS) and are
|
|
# EXPECTED to fail there, so a failure here is neither a regression nor an
|
|
# unclassified failure.
|
|
"behavior": "lifecycle_behavior_tests.txt",
|
|
}
|
|
|
|
|
|
def load_entries(path: pathlib.Path) -> list[str]:
|
|
names: list[str] = []
|
|
if not path.is_file():
|
|
return names
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#"):
|
|
names.append(line)
|
|
return names
|
|
|
|
|
|
def classification_errors(entries: dict[str, list[str]]) -> list[str]:
|
|
errors: list[str] = []
|
|
lists = {key: set(names) for key, names in entries.items()}
|
|
for key, names in entries.items():
|
|
duplicates = sorted(name for name, count in Counter(names).items() if count > 1)
|
|
if duplicates:
|
|
errors.append(f"{LIST_FILES[key]} has duplicates: {', '.join(duplicates)}")
|
|
keys = tuple(lists)
|
|
for index, left in enumerate(keys):
|
|
for right in keys[index + 1 :]:
|
|
overlap = sorted(lists[left] & lists[right])
|
|
if overlap:
|
|
errors.append(f"{left}/{right} classifications overlap: {', '.join(overlap)}")
|
|
return errors
|
|
|
|
|
|
def base_name(testcase_name: str) -> str:
|
|
"""Strip pytest parametrization (test_foo[param]) to match list entries."""
|
|
return testcase_name.split("[", 1)[0]
|
|
|
|
|
|
def parse_junit(path: pathlib.Path) -> tuple[dict[str, str], list[str], list[tuple[str, str, str, str]]]:
|
|
"""Return exact statuses, pytest-timeout cases, and failure summaries."""
|
|
results: dict[str, str] = {}
|
|
timed_out: list[str] = []
|
|
failures: list[tuple[str, str, str, str]] = []
|
|
severity = {"skipped": 0, "passed": 1, "failed": 2, "error": 2}
|
|
root = ET.parse(path).getroot()
|
|
for case in root.iter("testcase"):
|
|
name = case.get("name", "")
|
|
if not name:
|
|
continue
|
|
if case.find("failure") is not None:
|
|
status = "failed"
|
|
elif case.find("error") is not None:
|
|
status = "error"
|
|
elif case.find("skipped") is not None:
|
|
status = "skipped"
|
|
else:
|
|
status = "passed"
|
|
prev = results.get(name)
|
|
if prev is None or severity[status] > severity[prev]:
|
|
results[name] = status
|
|
node = case.find("failure") if status == "failed" else case.find("error")
|
|
if node is not None:
|
|
details = " ".join(filter(None, [node.get("message", ""), node.text or ""]))
|
|
message = node.get("message") or next(iter((node.text or "").strip().splitlines()), "")
|
|
failures.append((case.get("classname", ""), name, case.get("time", "0"), message))
|
|
if re.search(r"\bTimeout\s*(?:>|\()", details, re.IGNORECASE):
|
|
timed_out.append(name)
|
|
return results, timed_out, failures
|
|
|
|
|
|
def collapse_results(results: dict[str, str]) -> dict[str, str]:
|
|
"""Collapse parametrized cases for classification-level reporting."""
|
|
collapsed: dict[str, str] = {}
|
|
severity = {"skipped": 0, "passed": 1, "failed": 2, "error": 2}
|
|
for exact_name, status in results.items():
|
|
name = base_name(exact_name)
|
|
previous = collapsed.get(name)
|
|
if previous is None or severity[status] > severity[previous]:
|
|
collapsed[name] = status
|
|
return collapsed
|
|
|
|
|
|
def load_collected_nodeids(path: pathlib.Path) -> set[str]:
|
|
names: set[str] = set()
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
nodeid = line.strip()
|
|
if nodeid:
|
|
names.add(nodeid.rsplit("::", 1)[-1])
|
|
return names
|
|
|
|
|
|
def render_section(title: str, rows: list[str], hint: str = "") -> list[str]:
|
|
lines = [f"## {title} ({len(rows)})", ""]
|
|
if hint:
|
|
lines += [hint, ""]
|
|
if rows:
|
|
lines += [f"- `{name}`" for name in sorted(rows)]
|
|
else:
|
|
lines.append("_none_")
|
|
lines.append("")
|
|
return lines
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--junit", type=pathlib.Path, help="junit.xml produced by pytest")
|
|
parser.add_argument(
|
|
"--lists-dir",
|
|
type=pathlib.Path,
|
|
default=pathlib.Path(__file__).resolve().parent,
|
|
help="directory containing the *_tests.txt classification files",
|
|
)
|
|
parser.add_argument("--output", type=pathlib.Path, help="write the markdown report to this path")
|
|
parser.add_argument(
|
|
"--fail-on-regression",
|
|
action="store_true",
|
|
help="exit non-zero when a test from implemented_tests.txt failed",
|
|
)
|
|
parser.add_argument(
|
|
"--fail-on-unclassified",
|
|
action="store_true",
|
|
help="exit non-zero when an executed test is absent from every classification",
|
|
)
|
|
parser.add_argument(
|
|
"--collected-nodeids",
|
|
type=pathlib.Path,
|
|
help="exact pytest node IDs from the pinned suite's collect-only pass",
|
|
)
|
|
parser.add_argument(
|
|
"--check-classifications-only",
|
|
action="store_true",
|
|
help="validate classification names against collected node IDs without reading JUnit",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
entries = {key: load_entries(args.lists_dir / fname) for key, fname in LIST_FILES.items()}
|
|
lists = {key: set(names) for key, names in entries.items()}
|
|
invalid_classifications = classification_errors(entries)
|
|
collected: set[str] = set()
|
|
if args.collected_nodeids:
|
|
collected = load_collected_nodeids(args.collected_nodeids)
|
|
collected_base = {base_name(name) for name in collected}
|
|
classified = set().union(*lists.values())
|
|
missing_classifications = sorted(collected_base - classified)
|
|
stale_classifications = sorted(classified - collected_base)
|
|
else:
|
|
missing_classifications = []
|
|
stale_classifications = []
|
|
|
|
if args.check_classifications_only:
|
|
if not args.collected_nodeids:
|
|
parser.error("--check-classifications-only requires --collected-nodeids")
|
|
for error in invalid_classifications:
|
|
print(f"[INVALID] {error}")
|
|
for name in missing_classifications:
|
|
print(f"[UNCLASSIFIED] {name}")
|
|
for name in stale_classifications:
|
|
print(f"[STALE] {name}")
|
|
return 1 if invalid_classifications or missing_classifications or stale_classifications else 0
|
|
|
|
if invalid_classifications:
|
|
for error in invalid_classifications:
|
|
print(f"[ERROR] {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
if not args.junit or not args.junit.is_file():
|
|
print(f"[ERROR] junit file not found: {args.junit}", file=sys.stderr)
|
|
return 2
|
|
|
|
exact_results, timed_out, failures = parse_junit(args.junit)
|
|
results = collapse_results(exact_results)
|
|
missing_results = sorted(collected - exact_results.keys()) if collected else []
|
|
|
|
regressions: list[str] = []
|
|
promotions: dict[str, list[str]] = {"unimplemented": [], "excluded": []}
|
|
unclassified_passed: list[str] = []
|
|
unclassified_failed: list[str] = []
|
|
behavior_passed: list[str] = []
|
|
counts = {"passed": 0, "failed": 0, "error": 0, "skipped": 0}
|
|
|
|
for name, status in results.items():
|
|
counts[status] += 1
|
|
if status in ("failed", "error"):
|
|
if name in lists["implemented"]:
|
|
regressions.append(name)
|
|
elif (
|
|
name not in lists["unimplemented"]
|
|
and name not in lists["excluded"]
|
|
and name not in lists["behavior"]
|
|
):
|
|
unclassified_failed.append(name)
|
|
elif status == "passed":
|
|
if name in lists["unimplemented"]:
|
|
promotions["unimplemented"].append(name)
|
|
elif name in lists["excluded"]:
|
|
promotions["excluded"].append(name)
|
|
elif name in lists["behavior"]:
|
|
behavior_passed.append(name)
|
|
elif name not in lists["implemented"]:
|
|
unclassified_passed.append(name)
|
|
|
|
lines = [
|
|
"# S3 compatibility report",
|
|
"",
|
|
f"Executed: {len(exact_results)} exact cases across {len(results)} classified tests.",
|
|
"",
|
|
"Classification status — "
|
|
f"{counts['passed']} passed, {counts['failed']} failed, "
|
|
f"{counts['error']} errored, {counts['skipped']} skipped.",
|
|
"",
|
|
]
|
|
lines += render_section(
|
|
"Regressions",
|
|
regressions,
|
|
"Listed in `implemented_tests.txt` but failing — these gate PRs and must be fixed.",
|
|
)
|
|
lines += render_section(
|
|
"Promotion candidates (from unimplemented_tests.txt)",
|
|
promotions["unimplemented"],
|
|
"Now passing — move to `implemented_tests.txt` to lock in the coverage.",
|
|
)
|
|
lines += render_section(
|
|
"Promotion candidates (from excluded_tests.txt)",
|
|
promotions["excluded"],
|
|
"Passing despite being excluded — re-evaluate the exclusion.",
|
|
)
|
|
lines += render_section(
|
|
"Lifecycle behavior lane (passing in this run)",
|
|
behavior_passed,
|
|
"Gated by the dedicated `s3-lifecycle-behavior-tests` lane; expected to "
|
|
"fail in the plain `scope=all` sweep (no debug day / scanner).",
|
|
)
|
|
lines += render_section(
|
|
"Unclassified passes",
|
|
unclassified_passed,
|
|
"Passing but absent from every list — add to `implemented_tests.txt`.",
|
|
)
|
|
lines += render_section(
|
|
"Unclassified failures",
|
|
unclassified_failed,
|
|
"Failing and absent from every list — triage into `unimplemented_tests.txt` or `excluded_tests.txt`.",
|
|
)
|
|
lines += render_section(
|
|
"Missing results",
|
|
missing_results,
|
|
"Present in the pinned upstream suite but absent from JUnit — the sweep was incomplete.",
|
|
)
|
|
lines += render_section(
|
|
"Timed out",
|
|
timed_out,
|
|
"Per-test timeout is an infrastructure failure regardless of compatibility classification.",
|
|
)
|
|
|
|
report = "\n".join(lines)
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(report, encoding="utf-8")
|
|
print(f"[INFO] Report written to {args.output}")
|
|
|
|
print(
|
|
f"[INFO] {len(regressions)} regression(s), "
|
|
f"{len(promotions['unimplemented']) + len(promotions['excluded']) + len(unclassified_passed)} promotion candidate(s), "
|
|
f"{len(unclassified_failed)} unclassified failure(s), "
|
|
f"{len(missing_results)} missing result(s), "
|
|
f"{len(timed_out)} timeout(s)"
|
|
)
|
|
for name in sorted(regressions):
|
|
print(f"[REGRESSION] {name}")
|
|
if failures:
|
|
print("[ERROR] s3-tests failed testcase summary:")
|
|
for classname, name, duration, message in failures[:20]:
|
|
nodeid = f"{classname}::{name}" if classname else name
|
|
print(f"[ERROR] - {nodeid} ({duration}s): {message}")
|
|
if len(failures) > 20:
|
|
print(f"[ERROR] - ... {len(failures) - 20} additional failed testcases omitted")
|
|
|
|
if args.fail_on_regression and regressions:
|
|
return 1
|
|
if args.fail_on_unclassified and (unclassified_passed or unclassified_failed):
|
|
return 1
|
|
if args.collected_nodeids and missing_results:
|
|
return 1
|
|
if timed_out:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|