ReadViewSDK/scripts/summarize_ui_results.py
shenlei 0e7c0577e3 feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:34:50 +08:00

466 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@dataclass
class FailureItem:
test_name: str
target_name: str
category: str
summary: str
failure_text: str
test_identifier: str | None = None
def run_json_command(command: list[str]) -> dict[str, Any] | list[Any] | None:
try:
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None
stdout = completed.stdout.strip()
if not stdout:
return None
try:
return json.loads(stdout)
except json.JSONDecodeError:
return None
def compact_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def summarize_failure_text(text: str) -> str:
compact = compact_text(text)
if not compact:
return "No failure message captured"
for delimiter in (" - ", ". ", "\n", ""):
if delimiter in compact:
head = compact.split(delimiter, 1)[0].strip()
if len(head) >= 12:
return head[:220]
return compact[:220]
def classify_failure(text: str, test_name: str) -> str:
haystack = f"{test_name} {text}".lower()
if any(token in haystack for token in [
"timed out",
"timeout",
"failed to fulfill",
"waitfordemoreaderstate",
"waitforexistence",
"等待",
"超时",
]):
return "Timeout"
if any(token in haystack for token in [
"no matches found",
"failed to find",
"does not exist",
"existence",
"not hittable",
"不存在",
"未出现",
]):
return "Element Missing"
if any(token in haystack for token in [
"simulator",
"lost connection",
"test runner exited",
"application state",
"quit unexpectedly",
"connection interrupted",
"xcodebuild",
]):
return "Environment"
if any(token in haystack for token in [
"page",
"pagination",
"bookpagemap",
"parsems",
"readerstate",
"location",
"页码",
"分页",
"位置",
]):
return "Navigation/State"
if any(token in haystack for token in [
"xctassert",
"assertion",
"xctfail",
"failed:",
]):
return "Assertion Failure"
return "Functional Failure"
def normalize_root_cause(text: str) -> str:
compact = summarize_failure_text(text).lower()
compact = re.sub(r"\b\d+\b", "<n>", compact)
compact = re.sub(r"'[^']+'", "'<value>'", compact)
compact = re.sub(r'"[^"]+"', '"<value>"', compact)
return compact[:180]
def load_summary_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
return run_json_command([
"xcrun",
"xcresulttool",
"get",
"test-results",
"summary",
"--path",
str(xcresult_path),
"--compact",
])
def load_tests_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
return run_json_command([
"xcrun",
"xcresulttool",
"get",
"test-results",
"tests",
"--path",
str(xcresult_path),
"--compact",
])
def flatten_failed_test_nodes(node: dict[str, Any], out: list[dict[str, Any]]) -> None:
node_type = node.get("nodeType")
result = node.get("result")
if node_type == "Test Case" and result == "Failed":
out.append(node)
for child in node.get("children", []) or []:
flatten_failed_test_nodes(child, out)
def extract_failures(
summary: dict[str, Any] | None,
tests_payload: dict[str, Any] | None,
raw_log_path: Path | None,
) -> list[FailureItem]:
failures: list[FailureItem] = []
raw_failures = None
if summary:
raw_failures = summary.get("testFailures")
if isinstance(raw_failures, dict):
raw_failures = [raw_failures]
if isinstance(raw_failures, list):
for item in raw_failures:
if not isinstance(item, dict):
continue
test_name = item.get("testName") or "Unknown test"
target_name = item.get("targetName") or "Unknown target"
failure_text = item.get("failureText") or ""
failures.append(FailureItem(
test_name=test_name,
target_name=target_name,
category=classify_failure(failure_text, test_name),
summary=summarize_failure_text(failure_text),
failure_text=compact_text(failure_text),
test_identifier=item.get("testIdentifierString") or item.get("testIdentifierURL"),
))
if failures:
return dedupe_failures(failures)
if tests_payload:
failed_nodes: list[dict[str, Any]] = []
for node in tests_payload.get("testNodes", []) or []:
flatten_failed_test_nodes(node, failed_nodes)
for node in failed_nodes:
test_name = node.get("name") or "Unknown test"
details = node.get("details") or ""
failures.append(FailureItem(
test_name=test_name,
target_name="Unknown target",
category=classify_failure(details, test_name),
summary=summarize_failure_text(details),
failure_text=compact_text(details),
test_identifier=node.get("nodeIdentifier") or node.get("nodeIdentifierURL"),
))
if failures:
return dedupe_failures(failures)
if raw_log_path and raw_log_path.exists():
log_text = raw_log_path.read_text(encoding="utf-8", errors="replace")
matches = re.findall(
r"Test Case '-\[(.*?)\]' failed .*?\n(.*?)(?=\nTest Case '-\[|\Z)",
log_text,
flags=re.S,
)
for test_name, block in matches:
failure_text = compact_text(block)
failures.append(FailureItem(
test_name=test_name,
target_name="Unknown target",
category=classify_failure(failure_text, test_name),
summary=summarize_failure_text(failure_text),
failure_text=failure_text,
))
return dedupe_failures(failures)
def dedupe_failures(failures: list[FailureItem]) -> list[FailureItem]:
deduped: dict[tuple[str, str], FailureItem] = {}
for failure in failures:
key = (failure.test_name, failure.summary)
deduped.setdefault(key, failure)
return list(deduped.values())
def build_root_causes(failures: list[FailureItem]) -> list[dict[str, Any]]:
grouped: dict[str, list[FailureItem]] = defaultdict(list)
for failure in failures:
grouped[normalize_root_cause(failure.failure_text)].append(failure)
ranked = sorted(
grouped.items(),
key=lambda item: (-len(item[1]), item[0]),
)
result = []
for normalized_text, items in ranked[:5]:
result.append({
"summary": items[0].summary,
"category": items[0].category,
"count": len(items),
"tests": [failure.test_name for failure in items[:5]],
})
return result
def build_report_payload(
args: argparse.Namespace,
summary: dict[str, Any] | None,
tests_payload: dict[str, Any] | None,
failures: list[FailureItem],
) -> dict[str, Any]:
device_summaries = []
if summary:
devices_and_configurations = summary.get("devicesAndConfigurations")
if isinstance(devices_and_configurations, dict):
devices_and_configurations = [devices_and_configurations]
if isinstance(devices_and_configurations, list):
for item in devices_and_configurations:
if not isinstance(item, dict):
continue
device = item.get("device") or {}
config = item.get("testPlanConfiguration") or {}
device_summaries.append({
"device_name": device.get("deviceName"),
"model_name": device.get("modelName"),
"os_version": device.get("osVersion"),
"configuration_name": config.get("configurationName"),
"passed_tests": item.get("passedTests"),
"failed_tests": item.get("failedTests"),
})
categories = Counter(failure.category for failure in failures)
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"xcodebuild_exit_code": args.xcodebuild_exit_code,
"test_type": args.test_type,
"artifacts": {
"xcresult": str(Path(args.xcresult).resolve()) if args.xcresult else None,
"raw_log": str(Path(args.raw_log).resolve()) if args.raw_log else None,
},
"summary": {
"title": summary.get("title") if summary else None,
"environment_description": summary.get("environmentDescription") if summary else None,
"result": summary.get("result") if summary else ("Failed" if failures else "Passed"),
"total_test_count": summary.get("totalTestCount") if summary else None,
"passed_tests": summary.get("passedTests") if summary else None,
"failed_tests": summary.get("failedTests") if summary else len(failures),
"skipped_tests": summary.get("skippedTests") if summary else None,
"devices": device_summaries,
},
"failure_categories": dict(sorted(categories.items(), key=lambda item: (-item[1], item[0]))),
"top_root_causes": build_root_causes(failures),
"failures": [asdict(failure) for failure in failures],
"token_saving_notes": [
"Raw xcodebuild output is intentionally omitted from this report.",
"Use the xcresult or raw log paths only when a single failure needs deeper inspection.",
"Failures are deduplicated by test name and compact summary.",
],
}
if tests_payload and not payload["summary"]["total_test_count"]:
flat_failed_nodes: list[dict[str, Any]] = []
for node in tests_payload.get("testNodes", []) or []:
flatten_failed_test_nodes(node, flat_failed_nodes)
payload["summary"]["failed_tests"] = len(flat_failed_nodes)
return payload
def render_markdown(payload: dict[str, Any]) -> str:
summary = payload["summary"]
lines = [
"# UI Test Report",
"",
"## Snapshot",
"",
f"- Test type: `{payload.get('test_type')}`",
f"- Result: `{summary.get('result')}`",
f"- xcodebuild exit code: `{payload['xcodebuild_exit_code']}`",
f"- Total tests: `{summary.get('total_test_count')}`",
f"- Passed: `{summary.get('passed_tests')}`",
f"- Failed: `{summary.get('failed_tests')}`",
f"- Skipped: `{summary.get('skipped_tests')}`",
]
if summary.get("environment_description"):
lines.append(f"- Environment: `{summary['environment_description']}`")
artifacts = payload["artifacts"]
lines.extend([
f"- xcresult: `{artifacts.get('xcresult')}`",
f"- raw log: `{artifacts.get('raw_log')}`",
"",
])
if payload["failure_categories"]:
lines.extend([
"## Failure Categories",
"",
])
for category, count in payload["failure_categories"].items():
lines.append(f"- `{category}`: {count}")
lines.append("")
if payload["top_root_causes"]:
lines.extend([
"## Top Root Causes",
"",
])
for item in payload["top_root_causes"]:
lines.append(f"- `{item['category']}` x{item['count']}: {item['summary']}")
lines.append("")
lines.extend([
"## Failed Tests",
"",
])
if payload["failures"]:
for failure in payload["failures"]:
lines.append(
f"- `{failure['test_name']}` [{failure['category']}] {failure['summary']}"
)
else:
lines.append("- None")
lines.append("")
lines.extend([
"## Token-Saving Notes",
"",
])
for note in payload["token_saving_notes"]:
lines.append(f"- {note}")
lines.append("")
return "\n".join(lines)
def render_text_summary(payload: dict[str, Any]) -> str:
summary = payload["summary"]
total = summary.get("total_test_count")
passed = summary.get("passed_tests")
failed = summary.get("failed_tests")
skipped = summary.get("skipped_tests")
lines = [
"UI test summary",
f" type: {payload.get('test_type')}",
f" result: {summary.get('result')}",
f" tests: total={total} passed={passed} failed={failed} skipped={skipped}",
]
if payload["failure_categories"]:
category_text = ", ".join(
f"{name}={count}" for name, count in payload["failure_categories"].items()
)
lines.append(f" categories: {category_text}")
if payload["top_root_causes"]:
top = payload["top_root_causes"][0]
lines.append(f" top root cause: {top['summary']} ({top['count']} tests)")
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Summarize UI test xcresult output into a low-token report.")
parser.add_argument("--xcresult", help="Path to .xcresult bundle", default="")
parser.add_argument("--raw-log", help="Path to xcodebuild raw log", default="")
parser.add_argument("--output-md", required=True, help="Markdown report output path")
parser.add_argument("--output-json", required=True, help="JSON report output path")
parser.add_argument("--output-txt", required=True, help="Plain-text summary output path")
parser.add_argument("--test-type", default="unknown", help="Selected UI test group")
parser.add_argument("--xcodebuild-exit-code", type=int, required=True, help="Exit code from xcodebuild")
return parser.parse_args()
def main() -> int:
args = parse_args()
xcresult_path = Path(args.xcresult) if args.xcresult else None
raw_log_path = Path(args.raw_log) if args.raw_log else None
summary = None
tests_payload = None
if xcresult_path and xcresult_path.exists():
summary = load_summary_from_xcresult(xcresult_path)
tests_payload = load_tests_from_xcresult(xcresult_path)
failures = extract_failures(summary, tests_payload, raw_log_path)
payload = build_report_payload(args, summary, tests_payload, failures)
output_md = Path(args.output_md)
output_json = Path(args.output_json)
output_txt = Path(args.output_txt)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
output_md.write_text(render_markdown(payload) + "\n", encoding="utf-8")
output_txt.write_text(render_text_summary(payload) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())