mirror of
https://github.com/podman-container-tools/podman.git
synced 2026-09-20 06:37:49 +00:00
The step summary posted when a CI job fails is useless for the bindings suite: it carries a bare failure count and never the failure itself. Two reasons stack up. github_log_summary.py picks between the ginkgo and bats parsers by looking for "int-" in the file name, but int is not the only ginkgo suite - bindings runs ginkgo as well, so its log is handed to the bats parser, which finds nothing beyond the count in ginkgo's summary line. logformatter decides the layout from the log contents, so do the same here. That alone still leaves the summary empty. Only ginkgo run under -p reports a failure on the line logformatter reads the test status from, and `make testbindings` does not pass -p, so every test in a bindings log heads 'log-passed' and the stack trace div is the sole mark of a failure. Key on that div as well. Not on the class by itself: logformatter gives it to error-level logrus lines too, which passing tests emit, but those are spans. Three more things the script got wrong along the way, all of them kept out of sight by the "|| true" on the workflow step. The workflow passes it a glob but only argv[1] was ever read, so any further log was dropped without a word. A job that produces no html log at all leaves that glob unexpanded, which ended in a traceback. And logformatter reads its input through ':utf8', which validates nothing, so a test that wrote raw bytes leaves behind a log that the summary could not even decode. Signed-off-by: rjgoyln <pt40419@gmail.com>
189 lines
6.5 KiB
Python
Executable file
189 lines
6.5 KiB
Python
Executable file
#!/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:]))
|