|
|
|
"""Classes for storing and processing test results."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import collections.abc as c
|
|
|
|
import datetime
|
|
|
|
import typing as t
|
|
|
|
|
|
|
|
from .util import (
|
|
|
|
display,
|
|
|
|
)
|
|
|
|
|
|
|
|
from .util_common import (
|
|
|
|
get_docs_url,
|
|
|
|
write_text_test_results,
|
|
|
|
write_json_test_results,
|
|
|
|
ResultType,
|
|
|
|
)
|
|
|
|
|
|
|
|
from .metadata import (
|
|
|
|
Metadata,
|
|
|
|
)
|
|
|
|
|
|
|
|
from .config import (
|
|
|
|
TestConfig,
|
|
|
|
)
|
|
|
|
|
|
|
|
from . import junit_xml
|
|
|
|
|
|
|
|
|
|
|
|
def calculate_best_confidence(choices: tuple[tuple[str, int], ...], metadata: Metadata) -> int:
|
|
|
|
"""Return the best confidence value available from the given choices and metadata."""
|
|
|
|
best_confidence = 0
|
|
|
|
|
|
|
|
for path, line in choices:
|
|
|
|
confidence = calculate_confidence(path, line, metadata)
|
|
|
|
best_confidence = max(confidence, best_confidence)
|
|
|
|
|
|
|
|
return best_confidence
|
|
|
|
|
|
|
|
|
|
|
|
def calculate_confidence(path: str, line: int, metadata: Metadata) -> int:
|
|
|
|
"""Return the confidence level for a test result associated with the given file path and line number."""
|
|
|
|
ranges = metadata.changes.get(path)
|
|
|
|
|
|
|
|
# no changes were made to the file
|
|
|
|
if not ranges:
|
|
|
|
return 0
|
|
|
|
|
|
|
|
# changes were made to the same file and line
|
|
|
|
if any(r[0] <= line <= r[1] for r in ranges):
|
|
|
|
return 100
|
|
|
|
|
|
|
|
# changes were made to the same file and the line number is unknown
|
|
|
|
if line == 0:
|
|
|
|
return 75
|
|
|
|
|
|
|
|
# changes were made to the same file and the line number is different
|
|
|
|
return 50
|
|
|
|
|
|
|
|
|
|
|
|
class TestResult:
|
|
|
|
"""Base class for test results."""
|
|
|
|
|
|
|
|
def __init__(self, command: str, test: str, python_version: t.Optional[str] = None) -> None:
|
|
|
|
self.command = command
|
|
|
|
self.test = test
|
|
|
|
self.python_version = python_version
|
|
|
|
self.name = self.test or self.command
|
|
|
|
|
|
|
|
if self.python_version:
|
|
|
|
self.name += '-python-%s' % self.python_version
|
|
|
|
|
|
|
|
def write(self, args: TestConfig) -> None:
|
|
|
|
"""Write the test results to various locations."""
|
|
|
|
self.write_console()
|
|
|
|
self.write_bot(args)
|
|
|
|
|
|
|
|
if args.lint:
|
|
|
|
self.write_lint()
|
|
|
|
|
|
|
|
if args.junit:
|
|
|
|
self.write_junit(args)
|
|
|
|
|
|
|
|
def write_console(self) -> None:
|
|
|
|
"""Write results to console."""
|
|
|
|
|
|
|
|
def write_lint(self) -> None:
|
|
|
|
"""Write lint results to stdout."""
|
|
|
|
|
|
|
|
def write_bot(self, args: TestConfig) -> None:
|
|
|
|
"""Write results to a file for ansibullbot to consume."""
|
|
|
|
|
|
|
|
def write_junit(self, args: TestConfig) -> None:
|
|
|
|
"""Write results to a junit XML file."""
|
|
|
|
|
|
|
|
def create_result_name(self, extension: str) -> str:
|
|
|
|
"""Return the name of the result file using the given extension."""
|
|
|
|
name = 'ansible-test-%s' % self.command
|
|
|
|
|
|
|
|
if self.test:
|
|
|
|
name += '-%s' % self.test
|
|
|
|
|
|
|
|
if self.python_version:
|
|
|
|
name += '-python-%s' % self.python_version
|
|
|
|
|
|
|
|
name += extension
|
|
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
def save_junit(self, args: TestConfig, test_case: junit_xml.TestCase) -> None:
|
|
|
|
"""Save the given test case results to disk as JUnit XML."""
|
|
|
|
suites = junit_xml.TestSuites(
|
|
|
|
suites=[
|
|
|
|
junit_xml.TestSuite(
|
|
|
|
name='ansible-test',
|
|
|
|
cases=[test_case],
|
|
|
|
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
|
|
|
|
report = suites.to_pretty_xml()
|
|
|
|
|
|
|
|
if args.explain:
|
|
|
|
return
|
|
|
|
|
|
|
|
write_text_test_results(ResultType.JUNIT, self.create_result_name('.xml'), report)
|
|
|
|
|
|
|
|
|
|
|
|
class TestTimeout(TestResult):
|
|
|
|
"""Test timeout."""
|
|
|
|
|
|
|
|
def __init__(self, timeout_duration: int | float) -> None:
|
|
|
|
super().__init__(command='timeout', test='')
|
|
|
|
|
|
|
|
self.timeout_duration = timeout_duration
|
|
|
|
|
|
|
|
def write(self, args: TestConfig) -> None:
|
|
|
|
"""Write the test results to various locations."""
|
|
|
|
message = 'Tests were aborted after exceeding the %d minute time limit.' % self.timeout_duration
|
|
|
|
|
|
|
|
# Include a leading newline to improve readability on Shippable "Tests" tab.
|
|
|
|
# Without this, the first line becomes indented.
|
|
|
|
output = '''
|
|
|
|
One or more of the following situations may be responsible:
|
|
|
|
|
|
|
|
- Code changes have resulted in tests that hang or run for an excessive amount of time.
|
|
|
|
- Tests have been added which exceed the time limit when combined with existing tests.
|
|
|
|
- Test infrastructure and/or external dependencies are operating slower than normal.'''
|
|
|
|
|
|
|
|
if args.coverage:
|
|
|
|
output += '\n- Additional overhead from collecting code coverage has resulted in tests exceeding the time limit.'
|
|
|
|
|
|
|
|
output += '\n\nConsult the console log for additional details on where the timeout occurred.'
|
|
|
|
|
|
|
|
suites = junit_xml.TestSuites(
|
|
|
|
suites=[
|
|
|
|
junit_xml.TestSuite(
|
|
|
|
name='ansible-test',
|
|
|
|
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
|
|
|
|
cases=[
|
|
|
|
junit_xml.TestCase(
|
|
|
|
name='timeout',
|
|
|
|
classname='timeout',
|
|
|
|
errors=[
|
|
|
|
junit_xml.TestError(
|
|
|
|
message=message,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
],
|
|
|
|
)
|
|
|
|
|
|
|
|
report = suites.to_pretty_xml()
|
|
|
|
|
|
|
|
write_text_test_results(ResultType.JUNIT, self.create_result_name('.xml'), report)
|
|
|
|
|
|
|
|
|
|
|
|
class TestSuccess(TestResult):
|
|
|
|
"""Test success."""
|
|
|
|
|
|
|
|
def write_junit(self, args: TestConfig) -> None:
|
|
|
|
"""Write results to a junit XML file."""
|
|
|
|
test_case = junit_xml.TestCase(classname=self.command, name=self.name)
|
|
|
|
|
|
|
|
self.save_junit(args, test_case)
|
|
|
|
|
|
|
|
|
|
|
|
class TestSkipped(TestResult):
|
|
|
|
"""Test skipped."""
|
|
|
|
|
|
|
|
def __init__(self, command: str, test: str, python_version: t.Optional[str] = None) -> None:
|
|
|
|
super().__init__(command, test, python_version)
|
|
|
|
|
|
|
|
self.reason: t.Optional[str] = None
|
|
|
|
|
|
|
|
def write_console(self) -> None:
|
|
|
|
"""Write results to console."""
|
|
|
|
if self.reason:
|
|
|
|
display.warning(self.reason)
|
|
|
|
else:
|
|
|
|
display.info('No tests applicable.', verbosity=1)
|
|
|
|
|
|
|
|
def write_junit(self, args: TestConfig) -> None:
|
|
|
|
"""Write results to a junit XML file."""
|
|
|
|
test_case = junit_xml.TestCase(
|
|
|
|
classname=self.command,
|
|
|
|
name=self.name,
|
|
|
|
skipped=self.reason or 'No tests applicable.',
|
|
|
|
)
|
|
|
|
|
|
|
|
self.save_junit(args, test_case)
|
|
|
|
|
|
|
|
|
|
|
|
class TestFailure(TestResult):
|
|
|
|
"""Test failure."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
command: str,
|
|
|
|
test: str,
|
|
|
|
python_version: t.Optional[str] = None,
|
|
|
|
messages: t.Optional[c.Sequence[TestMessage]] = None,
|
|
|
|
summary: t.Optional[str] = None,
|
|
|
|
):
|
|
|
|
super().__init__(command, test, python_version)
|
|
|
|
|
|
|
|
if messages:
|
|
|
|
messages = sorted(messages)
|
|
|
|
else:
|
|
|
|
messages = []
|
|
|
|
|
|
|
|
self.messages = messages
|
|
|
|
self.summary = summary
|
|
|
|
|
|
|
|
def write(self, args: TestConfig) -> None:
|
|
|
|
"""Write the test results to various locations."""
|
|
|
|
if args.metadata.changes:
|
|
|
|
self.populate_confidence(args.metadata)
|
|
|
|
|
|
|
|
super().write(args)
|
|
|
|
|
|
|
|
def write_console(self) -> None:
|
|
|
|
"""Write results to console."""
|
|
|
|
if self.summary:
|
|
|
|
display.error(self.summary)
|
|
|
|
else:
|
|
|
|
if self.python_version:
|
|
|
|
specifier = ' on python %s' % self.python_version
|
|
|
|
else:
|
|
|
|
specifier = ''
|
|
|
|
|
|
|
|
display.error('Found %d %s issue(s)%s which need to be resolved:' % (len(self.messages), self.test or self.command, specifier))
|
|
|
|
|
|
|
|
for message in self.messages:
|
|
|
|
display.error(message.format(show_confidence=True))
|
|
|
|
|
|
|
|
doc_url = self.find_docs()
|
|
|
|
if doc_url:
|
|
|
|
display.info('See documentation for help: %s' % doc_url)
|
|
|
|
|
|
|
|
def write_lint(self) -> None:
|
|
|
|
"""Write lint results to stdout."""
|
|
|
|
if self.summary:
|
|
|
|
command = self.format_command()
|
|
|
|
message = 'The test `%s` failed. See stderr output for details.' % command
|
|
|
|
path = ''
|
|
|
|
message = TestMessage(message, path)
|
|
|
|
print(message) # display goes to stderr, this should be on stdout
|
|
|
|
else:
|
|
|
|
for message in self.messages:
|
|
|
|
print(message) # display goes to stderr, this should be on stdout
|
|
|
|
|
|
|
|
def write_junit(self, args: TestConfig) -> None:
|
|
|
|
"""Write results to a junit XML file."""
|
|
|
|
title = self.format_title()
|
|
|
|
output = self.format_block()
|
|
|
|
|
|
|
|
test_case = junit_xml.TestCase(
|
|
|
|
classname=self.command,
|
|
|
|
name=self.name,
|
|
|
|
failures=[
|
|
|
|
junit_xml.TestFailure(
|
|
|
|
message=title,
|
|
|
|
output=output,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
|
|
|
|
self.save_junit(args, test_case)
|
|
|
|
|
|
|
|
def write_bot(self, args: TestConfig) -> None:
|
|
|
|
"""Write results to a file for ansibullbot to consume."""
|
|
|
|
docs = self.find_docs()
|
|
|
|
message = self.format_title(help_link=docs)
|
|
|
|
output = self.format_block()
|
|
|
|
|
|
|
|
if self.messages:
|
|
|
|
verified = all((m.confidence or 0) >= 50 for m in self.messages)
|
|
|
|
else:
|
|
|
|
verified = False
|
|
|
|
|
|
|
|
bot_data = dict(
|
|
|
|
verified=verified,
|
|
|
|
docs=docs,
|
|
|
|
results=[
|
|
|
|
dict(
|
|
|
|
message=message,
|
|
|
|
output=output,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
|
|
|
|
if args.explain:
|
|
|
|
return
|
|
|
|
|
|
|
|
write_json_test_results(ResultType.BOT, self.create_result_name('.json'), bot_data)
|
|
|
|
|
|
|
|
def populate_confidence(self, metadata: Metadata) -> None:
|
|
|
|
"""Populate test result confidence using the provided metadata."""
|
|
|
|
for message in self.messages:
|
|
|
|
if message.confidence is None:
|
|
|
|
message.confidence = calculate_confidence(message.path, message.line, metadata)
|
|
|
|
|
|
|
|
def format_command(self) -> str:
|
|
|
|
"""Return a string representing the CLI command associated with the test failure."""
|
|
|
|
command = 'ansible-test %s' % self.command
|
|
|
|
|
|
|
|
if self.test:
|
|
|
|
command += ' --test %s' % self.test
|
|
|
|
|
|
|
|
if self.python_version:
|
|
|
|
command += ' --python %s' % self.python_version
|
|
|
|
|
|
|
|
return command
|
|
|
|
|
|
|
|
def find_docs(self) -> t.Optional[str]:
|
|
|
|
"""Return the docs URL for this test or None if there is no docs URL."""
|
|
|
|
if self.command != 'sanity':
|
|
|
|
return None # only sanity tests have docs links
|
|
|
|
|
|
|
|
filename = f'{self.test}.html' if self.test else ''
|
|
|
|
url = get_docs_url(f'https://docs.ansible.com/ansible-core/devel/dev_guide/testing/{self.command}/{filename}')
|
|
|
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
def format_title(self, help_link: t.Optional[str] = None) -> str:
|
|
|
|
"""Return a string containing a title/heading for this test failure, including an optional help link to explain the test."""
|
|
|
|
command = self.format_command()
|
|
|
|
|
|
|
|
if self.summary:
|
|
|
|
reason = 'the error'
|
|
|
|
else:
|
|
|
|
reason = '1 error' if len(self.messages) == 1 else '%d errors' % len(self.messages)
|
|
|
|
|
|
|
|
if help_link:
|
|
|
|
help_link_markup = ' [[explain](%s)]' % help_link
|
|
|
|
else:
|
|
|
|
help_link_markup = ''
|
|
|
|
|
|
|
|
title = 'The test `%s`%s failed with %s:' % (command, help_link_markup, reason)
|
|
|
|
|
|
|
|
return title
|
|
|
|
|
|
|
|
def format_block(self) -> str:
|
|
|
|
"""Format the test summary or messages as a block of text and return the result."""
|
|
|
|
if self.summary:
|
|
|
|
block = self.summary
|
|
|
|
else:
|
|
|
|
block = '\n'.join(m.format() for m in self.messages)
|
|
|
|
|
|
|
|
message = block.strip()
|
|
|
|
|
|
|
|
# Hack to remove ANSI color reset code from SubprocessError messages.
|
|
|
|
message = message.replace(display.clear, '')
|
|
|
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
class TestMessage:
|
|
|
|
"""Single test message for one file."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
message: str,
|
|
|
|
path: str,
|
|
|
|
line: int = 0,
|
|
|
|
column: int = 0,
|
|
|
|
level: str = 'error',
|
|
|
|
code: t.Optional[str] = None,
|
|
|
|
confidence: t.Optional[int] = None,
|
|
|
|
):
|
|
|
|
self.__path = path
|
|
|
|
self.__line = line
|
|
|
|
self.__column = column
|
|
|
|
self.__level = level
|
|
|
|
self.__code = code
|
|
|
|
self.__message = message
|
|
|
|
|
|
|
|
self.confidence = confidence
|
|
|
|
|
|
|
|
@property
|
|
|
|
def path(self) -> str:
|
|
|
|
"""Return the path."""
|
|
|
|
return self.__path
|
|
|
|
|
|
|
|
@property
|
|
|
|
def line(self) -> int:
|
|
|
|
"""Return the line number, or 0 if none is available."""
|
|
|
|
return self.__line
|
|
|
|
|
|
|
|
@property
|
|
|
|
def column(self) -> int:
|
|
|
|
"""Return the column number, or 0 if none is available."""
|
|
|
|
return self.__column
|
|
|
|
|
|
|
|
@property
|
|
|
|
def level(self) -> str:
|
|
|
|
"""Return the level."""
|
|
|
|
return self.__level
|
|
|
|
|
|
|
|
@property
|
|
|
|
def code(self) -> t.Optional[str]:
|
|
|
|
"""Return the code, if any."""
|
|
|
|
return self.__code
|
|
|
|
|
|
|
|
@property
|
|
|
|
def message(self) -> str:
|
|
|
|
"""Return the message."""
|
|
|
|
return self.__message
|
|
|
|
|
|
|
|
@property
|
|
|
|
def tuple(self) -> tuple[str, int, int, str, t.Optional[str], str]:
|
|
|
|
"""Return a tuple with all the immutable values of this test message."""
|
|
|
|
return self.__path, self.__line, self.__column, self.__level, self.__code, self.__message
|
|
|
|
|
|
|
|
def __lt__(self, other):
|
|
|
|
return self.tuple < other.tuple
|
|
|
|
|
|
|
|
def __le__(self, other):
|
|
|
|
return self.tuple <= other.tuple
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
return self.tuple == other.tuple
|
|
|
|
|
|
|
|
def __ne__(self, other):
|
|
|
|
return self.tuple != other.tuple
|
|
|
|
|
|
|
|
def __gt__(self, other):
|
|
|
|
return self.tuple > other.tuple
|
|
|
|
|
|
|
|
def __ge__(self, other):
|
|
|
|
return self.tuple >= other.tuple
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
return hash(self.tuple)
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return self.format()
|
|
|
|
|
|
|
|
def format(self, show_confidence: bool = False) -> str:
|
|
|
|
"""Return a string representation of this message, optionally including the confidence level."""
|
|
|
|
if self.__code:
|
|
|
|
msg = '%s: %s' % (self.__code, self.__message)
|
|
|
|
else:
|
|
|
|
msg = self.__message
|
|
|
|
|
|
|
|
if show_confidence and self.confidence is not None:
|
|
|
|
msg += ' (%d%%)' % self.confidence
|
|
|
|
|
|
|
|
return '%s:%s:%s: %s' % (self.__path, self.__line, self.__column, msg)
|