#!/usr/bin/env python3 from html.parser import HTMLParser import os import sys # The classes logformatter only ever emits for its ginkgo layout. Its bats # layout marks failures with 'bats-' classes instead. GINKGO_CLASS = 'log-failed' GINKGO_CLASS_PREFIX = 'ginkgo-' # Only ginkgo run under -p puts the [FAILED] marker on the line logformatter # reads the test status from, so a suite run without it - bindings, for one - # heads every test with 'log-passed' and leaves the stack trace as the sole # mark of a failure. Match that on the element rather than the class alone: # logformatter wraps the trace in a div, while the logrus lines and summary # links sharing the class are spans, and those appear in passing tests too. FAILURE_BLOCK_TAG = 'div' FAILURE_BLOCK_CLASS = 'log-error' class GinkgoLogFilterParser(HTMLParser): def __init__(self): super().__init__() # Stack to keep track of nested elements self.stack = [] # Store the raw HTML strings of matching 'tt' elements self.results = [] # Set once a class only the ginkgo layout emits has been seen self.is_ginkgo = False def _get_classes(self, attrs): """Helper to extract classes from an attribute list.""" for attr, value in attrs: if attr == 'class' and value: return value.split() return [] def _detect_ginkgo(self, classes): """Note the classes logformatter only emits for the ginkgo layout.""" if any(c == GINKGO_CLASS or c.startswith(GINKGO_CLASS_PREFIX) for c in classes): self.is_ginkgo = True def _marks_failure(self, tag, classes): """Whether this element marks the block around it as a failure.""" if GINKGO_CLASS in classes: return True # Deliberately not fed to _detect_ginkgo: bats logs carry this class # too, on any logrus line logged at error level. return tag == FAILURE_BLOCK_TAG and FAILURE_BLOCK_CLASS in classes def handle_starttag(self, tag, attrs): classes = self._get_classes(attrs) is_tt = 'tt' in classes is_failed = self._marks_failure(tag, classes) self._detect_ginkgo(classes) # If we see a failure mark, flag all 'tt' ancestors in the stack if is_failed: for node in self.stack: if node['is_tt']: node['keep'] = True # Push the new element to the stack self.stack.append({ 'tag': tag, 'text_chunks': [], 'is_tt': is_tt, 'keep': False }) def handle_startendtag(self, tag, attrs): # Handle self-closing tags just to check for the failure class classes = self._get_classes(attrs) self._detect_ginkgo(classes) if self._marks_failure(tag, classes): for node in self.stack: if node['is_tt']: node['keep'] = True def handle_data(self, data): # Capture raw text data if self.stack: self.stack[-1]['text_chunks'].append(data) def handle_endtag(self, tag): # Find the matching start tag in the stack for i in reversed(range(len(self.stack))): if self.stack[i]['tag'] == tag: # Pop the matching tag and any unclosed children while len(self.stack) > i: node = self.stack.pop() # Join all the collected text for this node node_text = "".join(node['text_chunks']) # Trim down the spaces for the ginkgo long indentation. node_text = node_text.removeprefix(' ' * 9) # If this is a 'tt' element holding a failure mark, save the text if node['is_tt'] and node['keep']: self.results.append(node_text) # Pass the text of this node up to its parent so we don't lose inner text if self.stack: self.stack[-1]['text_chunks'].append(node_text) break class BatsLogFilterParser(HTMLParser): def __init__(self): super().__init__() # Current Tag which text should be stored self.record = None self.data = "" def _get_classes(self, attrs): """Helper to extract classes from an attribute list.""" for attr, value in attrs: if attr == 'class' and value: return value.split() return [] def handle_starttag(self, tag, attrs): classes = self._get_classes(attrs) if 'bats-failed' in classes or 'bats-log-failblock' in classes or 'bats-log' in classes: self.record = tag def handle_data(self, data): if self.record: self.data += data def handle_endtag(self, tag): if self.record == tag: self.data += "\n" self.record = None def filter_html_file(file_path): # Read the HTML content. logformatter passes its input through ':utf8', # which does not validate, so a test that wrote raw bytes leaves us with a # log we must not choke on. with open(file_path, 'r', encoding='utf-8', errors='replace') as f: html_content = f.read() # logformatter picks the ginkgo or bats layout from the log contents, so # tell them apart the same way here. The file name is no guide: int is not # the only ginkgo suite, bindings is one too. ginkgo_parser = GinkgoLogFilterParser() ginkgo_parser.feed(html_content) if ginkgo_parser.is_ginkgo: return ginkgo_parser.results bats_parser = BatsLogFilterParser() bats_parser.feed(html_content) return [bats_parser.data] def main(file_paths): if not file_paths: print(f"usage: {os.path.basename(sys.argv[0])} LOGFILE.html...", file=sys.stderr) return 2 with_headings = len(file_paths) > 1 for file_path in file_paths: try: matching_elements = filter_html_file(file_path) except OSError as e: # The caller passes a glob, which the shell hands over unexpanded # when a job produced no html log at all. print(f"skipping {file_path}: {e}", file=sys.stderr) continue if with_headings: print(f"### {os.path.basename(file_path)}") for element in matching_elements: if not element.strip(): continue print("```") print(element) print("```") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))