Skip to content

Commit b80f4d5

Browse files
Merge pull request #292 from gilles-peskine-arm/analyze_outcomes-add_to_crypto-framework
Add outcome analysis to TF-PSA-Crypto: framework support
2 parents fc80671 + 8e06778 commit b80f4d5

1 file changed

Lines changed: 108 additions & 44 deletions

File tree

scripts/mbedtls_framework/outcome_analysis.py

Lines changed: 108 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,64 @@ def execute_reference_driver_tests(results: Results, ref_component: str, driver_
100100
if ret_val != 0:
101101
results.error("failed to run reference/driver components")
102102

103-
IgnoreEntry = typing.Union[str, typing.Pattern]
104103

105-
def name_matches_pattern(name: str, str_or_re: IgnoreEntry) -> bool:
106-
"""Check if name matches a pattern, that may be a string or regex.
107-
- If the pattern is a string, name must be equal to match.
108-
- If the pattern is a regex, name must fully match.
109-
"""
110-
# The CI's python is too old for re.Pattern
111-
#if isinstance(str_or_re, re.Pattern):
112-
if not isinstance(str_or_re, str):
113-
return str_or_re.fullmatch(name) is not None
114-
else:
115-
return str_or_re == name
104+
TestCaseMatcher = typing.Union[str, typing.Pattern]
105+
106+
# Map test suite names (with the test_suite prefix) to a list of ignored
107+
# test cases. Each element in the list can be either a string or a
108+
# compiled regex (Pattern). Strings only match themselves. Regexes must
109+
# match the full test case description (not just a prefix or other
110+
# substring).
111+
TestCaseSetDescription = typing.Mapping[str, typing.Sequence[TestCaseMatcher]]
112+
113+
class TestCaseSet:
114+
"""A set of test cases, indexed by their test suite."""
115+
#pylint: disable=too-few-public-methods
116+
117+
def __init__(self, description: TestCaseSetDescription) -> None:
118+
"""Construct a set of test cases from a list of matches for each test suite.
119+
"""
120+
# Construct new mutable objects, to avoid mutating the parameter,
121+
# which could be confusing.
122+
self.matchers = {key: list(entries)
123+
for key, entries in description.items()}
124+
125+
def extend(self, description: TestCaseSetDescription) -> None:
126+
"""Add more matchers to this test case set."""
127+
for key, entries in description.items():
128+
self.matchers.setdefault(key, [])
129+
self.matchers[key] += entries
130+
131+
@staticmethod
132+
def _name_matches_pattern(name: str, str_or_re: TestCaseMatcher) -> bool:
133+
"""Check if name matches a pattern, that may be a string or regex.
134+
- If the pattern is a string, name must be equal to match.
135+
- If the pattern is a regex, name must fully match.
136+
"""
137+
# The CI's python is too old for re.Pattern
138+
#if isinstance(str_or_re, re.Pattern):
139+
if not isinstance(str_or_re, str):
140+
return str_or_re.fullmatch(name) is not None
141+
else:
142+
return str_or_re == name
143+
144+
def _suite_matchers(self, test_suite: str) -> typing.Iterator[TestCaseMatcher]:
145+
"""Generate the matcher list for the specified test suite."""
146+
if test_suite in self.matchers:
147+
yield from self.matchers[test_suite]
148+
pos = test_suite.find('.')
149+
if pos != -1:
150+
base_test_suite = test_suite[:pos]
151+
if base_test_suite in self.matchers:
152+
yield from self.matchers[base_test_suite]
153+
154+
def contains(self, test_suite: str, test_string: str) -> bool:
155+
"""Check if the specified test case is in the set."""
156+
for str_or_re in self._suite_matchers(test_suite):
157+
if self._name_matches_pattern(test_string, str_or_re):
158+
return True
159+
return False
160+
116161

117162
def open_outcome_file(outcome_file: str) -> typing.TextIO:
118163
if outcome_file.endswith('.gz'):
@@ -146,11 +191,22 @@ def read_outcome_file(outcome_file: str) -> Outcomes:
146191
class Task:
147192
"""Base class for outcome analysis tasks."""
148193

149-
# Override the following in child classes.
150-
# Map test suite names (with the test_suite_prefix) to a list of ignored
151-
# test cases. Each element in the list can be either a string or a regex;
152-
# see the `name_matches_pattern` function.
153-
IGNORED_TESTS = {} #type: typing.Dict[str, typing.List[IgnoreEntry]]
194+
@staticmethod
195+
def _has_word_re(words: typing.Iterable[str],
196+
exclude: typing.Optional[str] = None) -> typing.Pattern:
197+
"""Construct a regex that matches if any of the words appears.
198+
199+
The occurrence must start and end at a word boundary.
200+
201+
If exclude is specified, strings containing a match for that
202+
regular expression will not match the returned pattern.
203+
"""
204+
exclude_clause = r''
205+
if exclude:
206+
exclude_clause = r'(?!.*' + exclude + ')'
207+
return re.compile(exclude_clause +
208+
r'.*\b(?:' + r'|'.join(words) + r')\b.*',
209+
re.DOTALL)
154210

155211
def __init__(self, options) -> None:
156212
"""Pass command line options to the tasks.
@@ -163,23 +219,6 @@ def section_name(self) -> str:
163219
"""The section name to use in results."""
164220
raise NotImplementedError
165221

166-
def ignored_tests(self, test_suite: str) -> typing.Iterator[IgnoreEntry]:
167-
"""Generate the ignore list for the specified test suite."""
168-
if test_suite in self.IGNORED_TESTS:
169-
yield from self.IGNORED_TESTS[test_suite]
170-
pos = test_suite.find('.')
171-
if pos != -1:
172-
base_test_suite = test_suite[:pos]
173-
if base_test_suite in self.IGNORED_TESTS:
174-
yield from self.IGNORED_TESTS[base_test_suite]
175-
176-
def is_test_case_ignored(self, test_suite: str, test_string: str) -> bool:
177-
"""Check if the specified test case is ignored."""
178-
for str_or_re in self.ignored_tests(test_suite):
179-
if name_matches_pattern(test_string, str_or_re):
180-
return True
181-
return False
182-
183222
def run(self, results: Results, outcomes: Outcomes):
184223
"""Run the analysis on the specified outcomes.
185224
@@ -192,17 +231,30 @@ class CoverageTask(Task):
192231
"""Analyze test coverage."""
193232

194233
# Test cases whose suite and description are matched by an entry in
195-
# IGNORED_TESTS are expected to be never executed.
234+
# UNCOVERED_TESTS are expected to be never executed.
235+
# Tests matched by IGNORED_TESTS are ignored entirely.
196236
# All other test cases are expected to be executed at least once.
197237

238+
UNCOVERED_TESTS: TestCaseSetDescription = {}
239+
IGNORED_TESTS: TestCaseSetDescription = {}
240+
198241
def __init__(self, options) -> None:
199242
super().__init__(options)
200243
self.full_coverage = options.full_coverage #type: bool
244+
self.uncovered_tests = TestCaseSet(self.UNCOVERED_TESTS)
245+
self.ignored_tests = TestCaseSet(self.IGNORED_TESTS)
201246

202247
@staticmethod
203248
def section_name() -> str:
204249
return "Analyze coverage"
205250

251+
def note_ignored_test(self, results: Results,
252+
test_suite: str, test_description: str) -> None:
253+
# pylint: disable=no-self-use # derived classes may need self
254+
"""This method runs for each test case that's available and ignored."""
255+
results.info('Test case was ignored: {};{}',
256+
test_suite, test_description)
257+
206258
def run(self, results: Results, outcomes: Outcomes) -> None:
207259
"""Check that all available test cases are executed at least once."""
208260
# Make sure that the generated data files are present (and up-to-date).
@@ -222,22 +274,28 @@ def run(self, results: Results, outcomes: Outcomes) -> None:
222274
suite_case in comp_outcomes.failures
223275
for comp_outcomes in outcomes.values())
224276
(test_suite, test_description) = suite_case.split(';')
225-
ignored = self.is_test_case_ignored(test_suite, test_description)
277+
ignored = self.ignored_tests.contains(test_suite, test_description)
278+
if ignored:
279+
self.note_ignored_test(results, test_suite, test_description)
280+
continue
226281

227-
if not hit and not ignored:
282+
uncovered = self.uncovered_tests.contains(test_suite, test_description)
283+
if not hit and not uncovered:
228284
if self.full_coverage:
229285
results.error('Test case not executed: {}', suite_case)
230286
else:
231287
results.warning('Test case not executed: {}', suite_case)
232-
elif hit and ignored:
288+
elif hit and uncovered:
233289
# If a test case is no longer always skipped, we should remove
234290
# it from the ignore list.
235291
if self.full_coverage:
236-
results.error('Test case was executed but marked as ignored for coverage: {}',
237-
suite_case)
292+
results.error(
293+
'Test case was executed but marked as uncovered for coverage: {}',
294+
suite_case)
238295
else:
239-
results.warning('Test case was executed but marked as ignored for coverage: {}',
240-
suite_case)
296+
results.warning(
297+
'Test case was executed but marked as uncovered for coverage: {}',
298+
suite_case)
241299

242300

243301
class DriverVSReference(Task):
@@ -257,12 +315,18 @@ class DriverVSReference(Task):
257315
# Configuration name (all.sh component) used as the driver.
258316
DRIVER = ''
259317
# Ignored test suites (without the test_suite_ prefix).
260-
IGNORED_SUITES = [] #type: typing.List[str]
318+
IGNORED_SUITES = [] #type: typing.Sequence[str]
319+
# Ignored test cases. Despite the name, these test cases are not
320+
# completely ignored: they must be skipped by driver tests. If they
321+
# are not skipped, this indicates a spurious entry and the analysis will
322+
# complain.
323+
IGNORED_TESTS: TestCaseSetDescription = {}
261324

262325
def __init__(self, options) -> None:
263326
super().__init__(options)
264327
self.ignored_suites = frozenset('test_suite_' + x
265328
for x in self.IGNORED_SUITES)
329+
self.ignored_tests = TestCaseSet(self.IGNORED_TESTS)
266330

267331
def section_name(self) -> str:
268332
return f"Analyze driver {self.DRIVER} vs reference {self.REFERENCE}"
@@ -299,7 +363,7 @@ def run(self, results: Results, outcomes: Outcomes) -> None:
299363
# For ignored test cases inside test suites, just remember and:
300364
# don't issue an error if they're skipped with drivers,
301365
# but issue an error if they're not (means we have a bad entry).
302-
ignored = self.is_test_case_ignored(full_test_suite, test_string)
366+
ignored = self.ignored_tests.contains(full_test_suite, test_string)
303367

304368
if not ignored and not suite_case in driver_outcomes.successes:
305369
results.error("SKIP/FAIL -> PASS: {}", suite_case)

0 commit comments

Comments
 (0)