| #!/usr/bin/env vpython3 |
| # Copyright 2026 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Fetches tryjob build statuses, per-test failure logs, text diffs, and |
| screenshot artifacts (actual_image, expected_image, image_diff) for a Gerrit CL. |
| |
| Reuses ResultDB and Buildbucket helpers from scripts/tools/update_goldens.py. |
| """ |
| |
| import argparse |
| from concurrent.futures import ThreadPoolExecutor |
| from html.parser import HTMLParser |
| import json |
| import os |
| import re |
| import subprocess |
| import sys |
| |
| TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| if TOOLS_DIR not in sys.path: |
| sys.path.insert(0, TOOLS_DIR) |
| |
| import update_goldens # pylint: disable=wrong-import-position |
| |
| IMAGE_ARTIFACT_IDS = { |
| 'actual_image', |
| 'expected_image', |
| 'image_diff', |
| 'generated', |
| } |
| |
| TEXT_ARTIFACT_IDS = { |
| 'stderr', |
| 'stdout', |
| 'crash_log', |
| 'command', |
| 'snippet', |
| 'actual_text', |
| 'expected_text', |
| 'text_diff', |
| 'pretty_text_diff', |
| } |
| |
| |
| class _HTMLStripper(HTMLParser): |
| """Strips HTML tags from ResultDB summaryHtml while preserving line breaks.""" |
| |
| def __init__(self): |
| super().__init__() |
| self._parts = [] |
| |
| def handle_starttag(self, tag, attrs): |
| if tag in ('br', 'p', 'div', 'pre', 'li', 'tr'): |
| self._parts.append('\n') |
| |
| def handle_data(self, data): |
| self._parts.append(data) |
| |
| def get_text(self): |
| return ''.join(self._parts).strip() |
| |
| def error(self, message): |
| pass |
| |
| |
| def strip_html(html_text): |
| """Converts ResultDB summaryHtml into readable plain text.""" |
| if not html_text: |
| return '' |
| parser = _HTMLStripper() |
| parser.feed(html_text) |
| return parser.get_text() |
| |
| |
| def sanitize_filename(name, max_len=120): |
| """Sanitizes a test ID or step name for safe filesystem directory names.""" |
| sanitized = re.sub(r'[^A-Za-z0-9._-]+', '_', name).strip('_') |
| if len(sanitized) > max_len: |
| sanitized = sanitized[:max_len] |
| return sanitized or 'unknown' |
| |
| |
| def get_artifact_extension(artifact): |
| """Determines the file extension for a ResultDB artifact.""" |
| artifact_id = artifact.get('artifactId', '') |
| content_type = artifact.get('contentType', '') |
| if artifact_id in IMAGE_ARTIFACT_IDS or content_type.startswith('image/'): |
| return '.png' |
| if content_type == 'text/html' or artifact_id.endswith('_html'): |
| return '.html' |
| return '.txt' |
| |
| |
| def should_download_artifact(artifact): |
| """Returns True if an artifact is an image, diff, or test failure log.""" |
| artifact_id = artifact.get('artifactId', '') |
| content_type = artifact.get('contentType', '') |
| if artifact_id in IMAGE_ARTIFACT_IDS or artifact_id in TEXT_ARTIFACT_IDS: |
| return True |
| if content_type.startswith('image/') or content_type.startswith('text/'): |
| return True |
| return False |
| |
| |
| def format_failure_log(detail): |
| """Formats a comprehensive plain-text failure log from a ResultDB test result.""" |
| lines = [ |
| f"Test ID: {detail.get('testId', '')}", |
| f"Result Name: {detail.get('name', '')}", |
| f"Status: {detail.get('status', '')}", |
| f"Expected: {detail.get('expected', False)}", |
| f"Duration: {detail.get('duration', '')}", |
| ] |
| tags = detail.get('tags', []) |
| if tags: |
| lines.append('\nTags:') |
| for tag in tags: |
| lines.append(f" {tag.get('key')}: {tag.get('value')}") |
| |
| failure_reason = detail.get('failureReason') or {} |
| primary_error = failure_reason.get('primaryErrorMessage') |
| if primary_error: |
| lines.append('\nPrimary Error Message:') |
| lines.append(primary_error) |
| |
| errors = failure_reason.get('errors') or [] |
| if len(errors) > 1: |
| lines.append('\nAdditional Errors:') |
| for err in errors: |
| msg = err.get('message') |
| if msg: |
| lines.append(msg) |
| |
| summary_html = detail.get('summaryHtml') |
| if summary_html: |
| lines.append('\nSummary Output:') |
| lines.append(strip_html(summary_html)) |
| |
| return '\n'.join(lines) + '\n' |
| |
| |
| def fetch_failed_step_logs(build_id, builder_dir): |
| """Fetches failed step logs via `bb get` and `bb log` when no RDB results exist.""" |
| step_logs = {} |
| try: |
| stdout = subprocess.check_output( |
| ['bb', 'get', str(build_id), '-steps', '-json'], text=True) |
| build_data = json.loads(stdout) |
| except Exception: # pylint: disable=broad-except |
| return step_logs |
| |
| for step in build_data.get('steps', []): |
| if step.get('status') not in ('FAILURE', 'INFRA_FAILURE'): |
| continue |
| step_name = step.get('name', '') |
| if not step_name: |
| continue |
| safe_step = sanitize_filename(step_name) |
| log_path = os.path.join(builder_dir, f'step_{safe_step}.log') |
| try: |
| log_output = subprocess.check_output( |
| ['bb', 'log', str(build_id), step_name], |
| text=True, |
| stderr=subprocess.STDOUT) |
| os.makedirs(builder_dir, exist_ok=True) |
| with open(log_path, 'w', encoding='utf-8') as log_file: |
| log_file.write(log_output) |
| step_logs[step_name] = log_path |
| except Exception: # pylint: disable=broad-except |
| continue |
| return step_logs |
| |
| |
| def process_failed_test(detail, artifacts_for_test, builder, builder_dir, |
| test_filter_re, apply_goldens): |
| """Writes failure_log.txt and downloads pre-fetched artifacts for a test.""" |
| test_id = detail.get('testId', '') |
| screenshot_path = update_goldens.get_screenshot_path(detail) |
| if test_filter_re and not ( |
| test_filter_re.search(test_id) |
| or test_filter_re.search(detail.get('name', '')) or |
| (screenshot_path and test_filter_re.search(screenshot_path))): |
| return None |
| safe_test_id = sanitize_filename(test_id) |
| test_dir = os.path.join(builder_dir, safe_test_id) |
| os.makedirs(test_dir, exist_ok=True) |
| |
| failure_log_path = os.path.join(test_dir, 'failure_log.txt') |
| with open(failure_log_path, 'w', encoding='utf-8') as log_file: |
| log_file.write(format_failure_log(detail)) |
| |
| downloaded_artifacts = {} |
| applied_golden_path = None |
| |
| for artifact in artifacts_for_test: |
| if not should_download_artifact(artifact): |
| continue |
| artifact_id = artifact['artifactId'] |
| fetch_url = artifact.get('fetchUrl') |
| if not fetch_url: |
| continue |
| ext = get_artifact_extension(artifact) |
| filename = artifact_id if artifact_id.endswith( |
| ext) else f'{artifact_id}{ext}' |
| dest_path = os.path.join(test_dir, filename) |
| update_goldens.download_individual_screenshot(dest_path, fetch_url) |
| downloaded_artifacts[artifact_id] = dest_path |
| |
| if (apply_goldens and screenshot_path and 'linux' in builder |
| and artifact_id in ('actual_image', 'generated')): |
| update_goldens.download_individual_screenshot( |
| screenshot_path, fetch_url) |
| applied_golden_path = screenshot_path |
| |
| failure_reason = detail.get('failureReason') or {} |
| return { |
| 'testId': test_id, |
| 'status': detail.get('status'), |
| 'primaryErrorMessage': failure_reason.get('primaryErrorMessage', ''), |
| 'screenshotPath': screenshot_path, |
| 'failureLogPath': failure_log_path, |
| 'artifacts': downloaded_artifacts, |
| 'appliedGoldenPath': applied_golden_path, |
| } |
| |
| |
| def collect_cl_results(cl=None, |
| patchset=None, |
| builder_pattern=None, |
| test_filter=None, |
| output_dir=None, |
| apply_goldens=False): |
| """Collects build statuses, failure logs, and artifacts for a CL/patchset.""" |
| raw_builds = update_goldens.read_try_results(patchset=patchset, cl=cl) |
| latest_builds = update_goldens.filter_last_results(raw_builds) |
| |
| builder_re = re.compile(builder_pattern) if builder_pattern else None |
| test_filter_re = re.compile(test_filter) if test_filter else None |
| |
| if not output_dir: |
| output_dir = os.path.abspath('cl_test_results') |
| os.makedirs(output_dir, exist_ok=True) |
| |
| summary = { |
| 'cl': cl, |
| 'patchset': patchset, |
| 'outputDir': output_dir, |
| 'builders': {}, |
| } |
| |
| for builder, build_info in sorted(latest_builds.items()): |
| if builder_re and not builder_re.search(builder): |
| continue |
| if summary['cl'] is None: |
| summary['cl'] = build_info.get('cl') |
| if summary['patchset'] is None: |
| summary['patchset'] = build_info.get('patch') |
| |
| builder_entry = { |
| 'buildId': build_info['id'], |
| 'status': build_info['status'], |
| 'patch': build_info['patch'], |
| 'summaryMarkdown': build_info.get('summaryMarkdown', ''), |
| 'failedTests': [], |
| 'failedStepLogs': {}, |
| } |
| |
| if build_info['status'] not in ('SUCCESS', 'STARTED', 'SCHEDULED'): |
| builder_dir = os.path.join(output_dir, sanitize_filename(builder)) |
| unexpected = update_goldens.get_unexpected_results( |
| build_info['id']) |
| artifacts_by_result = update_goldens.get_unexpected_artifacts( |
| build_info['id']) |
| |
| unique_failures = [] |
| seen_test_ids = set() |
| for result_entry in unexpected.get('testResults', []): |
| if result_entry.get('expected'): |
| continue |
| if result_entry.get('status') not in ('FAIL', 'CRASH', |
| 'ABORT'): |
| continue |
| test_id = result_entry.get('testId', '') |
| if test_id in seen_test_ids: |
| continue |
| seen_test_ids.add(test_id) |
| unique_failures.append(result_entry) |
| |
| if unique_failures: |
| with ThreadPoolExecutor(max_workers=16) as executor: |
| futures = [ |
| executor.submit( |
| process_failed_test, result_entry, |
| artifacts_by_result.get(result_entry['name'], |
| []), builder, builder_dir, |
| test_filter_re, apply_goldens) |
| for result_entry in unique_failures |
| ] |
| for future in futures: |
| test_report = future.result() |
| if test_report: |
| builder_entry['failedTests'].append(test_report) |
| |
| if not builder_entry['failedTests'] and not test_filter_re: |
| builder_entry['failedStepLogs'] = fetch_failed_step_logs( |
| build_info['id'], builder_dir) |
| |
| summary['builders'][builder] = builder_entry |
| |
| summary_path = os.path.join(output_dir, 'summary.json') |
| with open(summary_path, 'w', encoding='utf-8') as summary_file: |
| json.dump(summary, summary_file, indent=2) |
| summary['summaryJsonPath'] = summary_path |
| return summary |
| |
| |
| def print_human_summary(summary): |
| """Prints a concise human- and agent-readable summary of CL results.""" |
| print(f"CL {summary['cl']} (Patchset {summary['patchset']})") |
| print(f"Artifacts & logs directory: {summary['outputDir']}") |
| print(f"Summary JSON: {summary['summaryJsonPath']}\n") |
| |
| for builder, info in summary['builders'].items(): |
| print(f"[{info['status']}] {builder} (build {info['buildId']})") |
| for test in info['failedTests']: |
| print(f" FAILED TEST: {test['testId']}") |
| if test['primaryErrorMessage']: |
| first_line = test['primaryErrorMessage'].splitlines()[0] |
| print(f" Error: {first_line}") |
| if test['screenshotPath']: |
| print(f" Screenshot Golden Path: {test['screenshotPath']}") |
| if test['appliedGoldenPath']: |
| print(f" Applied Golden: {test['appliedGoldenPath']}") |
| print(f" Failure Log: {test['failureLogPath']}") |
| for art_id, art_path in sorted(test['artifacts'].items()): |
| print(f" Artifact [{art_id}]: {art_path}") |
| for step_name, log_path in info['failedStepLogs'].items(): |
| print(f" FAILED STEP [{step_name}]: {log_path}") |
| |
| |
| def build_parser(): |
| """Builds the CLI argument parser.""" |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| parser.add_argument('--cl', |
| type=int, |
| help='Gerrit CL number (auto-detected if omitted).') |
| parser.add_argument('--patchset', |
| type=int, |
| help='Patchset number (latest if omitted).') |
| parser.add_argument('--builder', |
| help='Regex to filter builders (e.g. "dtf_linux.*").') |
| parser.add_argument( |
| '--test-filter', |
| help='Regex to filter failing test IDs (e.g. "DOMTreeWidget").') |
| parser.add_argument( |
| '--output-dir', |
| default='cl_test_results', |
| help='Directory to store failure logs and downloaded artifacts.') |
| parser.add_argument( |
| '--apply-goldens', |
| action='store_true', |
| help='Also copy Linux actual_image artifacts directly to test/goldens/.' |
| ) |
| parser.add_argument('--json', |
| action='store_true', |
| help='Emit full JSON report to stdout.') |
| return parser |
| |
| |
| def main(argv=None): |
| parser = build_parser() |
| options = parser.parse_args(argv) |
| summary = collect_cl_results(cl=options.cl, |
| patchset=options.patchset, |
| builder_pattern=options.builder, |
| test_filter=options.test_filter, |
| output_dir=options.output_dir, |
| apply_goldens=options.apply_goldens) |
| if options.json: |
| print(json.dumps(summary, indent=2)) |
| else: |
| print_human_summary(summary) |
| |
| |
| if __name__ == '__main__': |
| main() |