|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Copyright 2025 The Cobalt Authors. All Rights Reserved. |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | +"""Runs a test binary with retries on crash.""" |
| 16 | + |
| 17 | +import argparse |
| 18 | +import datetime |
| 19 | +import html |
| 20 | +import os |
| 21 | +import pathlib |
| 22 | +import re |
| 23 | +import subprocess |
| 24 | +import sys |
| 25 | +import time |
| 26 | + |
| 27 | +RUN_MARKER = '[ RUN ]' |
| 28 | +END_MARKERS = ( |
| 29 | + '[ OK ]', |
| 30 | + '[ FAILED ]', |
| 31 | + '[ SKIPPED ]', |
| 32 | +) |
| 33 | + |
| 34 | +def _get_test_name_from_run_line(line: str) -> str: |
| 35 | + """Extracts test name like 'Suite.Test' from a gtest marker line.""" |
| 36 | + match = re.search(rf"{re.escape(RUN_MARKER)}\s*([^\s]+)$", line) |
| 37 | + return match.group(1) if match else 'UnknownSuite.UnknownTest' |
| 38 | + |
| 39 | +def _extract_crash_info(log_path: pathlib.Path): |
| 40 | + """ |
| 41 | + Identifies the crashed test and its log output from a gtest log file. |
| 42 | + A crashed test will have a run marker but no end marker. |
| 43 | +
|
| 44 | + Returns: |
| 45 | + A tuple `(test_suite, test_name, log_output_for_crashed_test)`. |
| 46 | + Returns `("UnknownSuite", "UnknownTest", "")` if no crash is detected. |
| 47 | + """ |
| 48 | + if not log_path.exists(): |
| 49 | + return 'UnknownSuite', 'UnknownTest', '' |
| 50 | + |
| 51 | + with log_path.open('r', encoding='utf-8', errors='replace') as f: |
| 52 | + lines = f.readlines() |
| 53 | + |
| 54 | + for i, line in reversed(list(enumerate(lines))): |
| 55 | + if RUN_MARKER in line: |
| 56 | + log = ''.join(lines[i+1:]) |
| 57 | + if not any(marker in log for marker in END_MARKERS): |
| 58 | + test_name = _get_test_name_from_run_line(line) |
| 59 | + suite, name = test_name.split('.', 1) if '.' in test_name else ('UnknownSuite', test_name) |
| 60 | + return suite, name, log |
| 61 | + break |
| 62 | + |
| 63 | + return 'UnknownSuite', 'UnknownTest', '' |
| 64 | + |
| 65 | +def print_junit_xml(xml_path: pathlib.Path, crashed_test: list[tuple[str, str, str]]): |
| 66 | + now = datetime.datetime.now(datetime.timezone.utc) |
| 67 | + with xml_path.open('w', encoding='utf-8') as f: |
| 68 | + f.write(f"""<?xml version="1.0" encoding="UTF-8"?> |
| 69 | +<testsuites tests="1" failures="0" disabled="0" errors="1" time="0"> |
| 70 | + <testsuite name="{html.escape(suite)}" tests="1" failures="0" disabled="0" errors="1" time="0" timestamp="{now.strftime('%Y-%m-%dT%H:%M:%SZ')}"> |
| 71 | + <testcase name="{html.escape(name)}" classname="{html.escape(suite)}" time="0"> |
| 72 | + <error message="Test crashed"> |
| 73 | + <![CDATA[ {log} ]]> |
| 74 | + </error> |
| 75 | + </testcase> |
| 76 | + </testsuite> |
| 77 | +</testsuites> |
| 78 | +""") |
| 79 | + |
| 80 | +def main(): |
| 81 | + parser = argparse.ArgumentParser( |
| 82 | + description='Runs a test command with retries on crash.') |
| 83 | + parser.add_argument( |
| 84 | + '--xml_output_file', |
| 85 | + required=True, |
| 86 | + help='Path to the JUnit XML output file.') |
| 87 | + parser.add_argument( |
| 88 | + '--log_file', |
| 89 | + required=True, |
| 90 | + help='Path to the test log file.') |
| 91 | + parser.add_argument( |
| 92 | + '--max_retries', |
| 93 | + type=int, |
| 94 | + default=100, |
| 95 | + help='Maximum number of retries.') |
| 96 | + parser.add_argument( |
| 97 | + '--filter_file', |
| 98 | + help='Path to a file to store the crash filter.') |
| 99 | + parser.add_argument( |
| 100 | + 'command', |
| 101 | + nargs=argparse.REMAINDER, |
| 102 | + help='The command to run.') |
| 103 | + |
| 104 | + args = parser.parse_args() |
| 105 | + |
| 106 | + # Ensure command is not empty |
| 107 | + if not args.command: |
| 108 | + print("Error: No command provided.", file=sys.stderr) |
| 109 | + sys.exit(1) |
| 110 | + |
| 111 | + # Check if -- is passed as the first argument in command and remove it |
| 112 | + command = args.command |
| 113 | + if command and command[0] == '--': |
| 114 | + command = command[1:] |
| 115 | + |
| 116 | + xml_path = pathlib.Path(args.xml_output_file) |
| 117 | + log_path = pathlib.Path(args.log_file) |
| 118 | + max_retries = args.max_retries |
| 119 | + current_filter = None |
| 120 | + |
| 121 | + # Initial filter scan to find existing filter in command |
| 122 | + # We assume the filter is passed as --gtest_filter=... |
| 123 | + # If not present, we will inject it. |
| 124 | + filter_arg_index = -1 |
| 125 | + for i, arg in enumerate(command): |
| 126 | + if arg.startswith('--gtest_filter='): |
| 127 | + current_filter = arg.split('=', 1)[1] |
| 128 | + filter_arg_index = i |
| 129 | + break |
| 130 | + |
| 131 | + if current_filter == '*': |
| 132 | + current_filter = '' |
| 133 | + if filter_arg_index != -1: |
| 134 | + del command[filter_arg_index] # Remove the * filter argument |
| 135 | + filter_arg_index = -1 |
| 136 | + |
| 137 | + for attempt in range(max_retries + 1): |
| 138 | + # Construct command with current filter |
| 139 | + cmd = list(command) |
| 140 | + if current_filter: |
| 141 | + if filter_arg_index != -1 and filter_arg_index < len(cmd): |
| 142 | + cmd[filter_arg_index] = f'--gtest_filter={current_filter}' |
| 143 | + else: |
| 144 | + cmd.append(f'--gtest_filter={current_filter}') |
| 145 | + filter_arg_index = len(cmd) - 1 |
| 146 | + |
| 147 | + # Ensure output directory exists |
| 148 | + xml_path.parent.mkdir(parents=True, exist_ok=True) |
| 149 | + if xml_path.exists(): |
| 150 | + xml_path.unlink() |
| 151 | + |
| 152 | + with log_path.open('w', encoding='utf-8') as log_file: |
| 153 | + # We use bufsize=0 (unbuffered) equivalent or line buffered to capture output live if needed, |
| 154 | + # but here we just redirect to file. |
| 155 | + # Bash used: ... 2>&1 | tee ${log_path} |
| 156 | + # We will pipe to stdout and file. |
| 157 | + process = subprocess.Popen( |
| 158 | + cmd, |
| 159 | + stdout=subprocess.PIPE, |
| 160 | + stderr=subprocess.STDOUT, |
| 161 | + text=True, |
| 162 | + bufsize=1 # Line buffered |
| 163 | + ) |
| 164 | + |
| 165 | + while True: |
| 166 | + line = process.stdout.readline() |
| 167 | + if not line and process.poll() is not None: |
| 168 | + break |
| 169 | + if line: |
| 170 | + sys.stdout.write(line) |
| 171 | + log_file.write(line) |
| 172 | + |
| 173 | + if xml_path.exists(): |
| 174 | + sys.exit(process.poll()) |
| 175 | + |
| 176 | + suite, name, _ = _extract_crash_info(log_path) |
| 177 | + |
| 178 | + if suite == 'UnknownSuite' and name == 'UnknownTest': |
| 179 | + print("Could not identify crashed test. Aborting retries.") |
| 180 | + sys.exit(1) # Unknown crash, cannot filter |
| 181 | + |
| 182 | + crashed_test = f"{suite}.{name}" |
| 183 | + print(f"Identified crashed test: {crashed_test}") |
| 184 | + |
| 185 | + # Update filter |
| 186 | + if not current_filter: |
| 187 | + current_filter = f"-{crashed_test}" |
| 188 | + elif '-' in current_filter: |
| 189 | + # Already has negative filter, append |
| 190 | + current_filter += f":{crashed_test}" |
| 191 | + else: |
| 192 | + # Has positive filter, assume we want to keep it and exclude crashed? |
| 193 | + # GTest filter syntax: Positive patterns [-Negative patterns] |
| 194 | + # If current_filter has no '-', acts as positive. |
| 195 | + current_filter += f"-{crashed_test}" |
| 196 | + |
| 197 | + print(f"Updated filter: {current_filter}") |
| 198 | + if args.filter_file: |
| 199 | + with open(args.filter_file, 'w') as f: |
| 200 | + f.write(current_filter) |
| 201 | + |
| 202 | + # If we reached here, we ran out of retries |
| 203 | + print("Max retries reached.") |
| 204 | + |
| 205 | + # Generate fake XML for the last crash |
| 206 | + # We reuse the last log analysis |
| 207 | + suite, name, log_content = _extract_crash_info(log_path) |
| 208 | + print_junit_xml(xml_path, suite, name, log_content) |
| 209 | + |
| 210 | + sys.exit(1) |
| 211 | + |
| 212 | +if __name__ == '__main__': |
| 213 | + main() |
0 commit comments