Skip to content

Commit

Permalink
mtest: implement TAP parsing
Browse files Browse the repository at this point in the history
This provides an initial support for parsing TAP output.  It detects failures
and skipped tests without relying on exit code, as well as early termination
of the test due to an error or a crash.

For now, subtests are not recorded in the TestRun object.  However, because the
TAP output goes on stdout, it is printed by --print-errorlogs when a test does
not behave as expected.  Handling subtests as TestRuns, and serializing them
to JSON, can be added later.

The parser was written specifically for Meson, and comes with its own
test suite.

Fixes mesonbuild#2923.
  • Loading branch information
bonzini committed Feb 21, 2019
1 parent 9f912a1 commit 5415b0c
Show file tree
Hide file tree
Showing 9 changed files with 505 additions and 10 deletions.
10 changes: 5 additions & 5 deletions docs/markdown/Reference-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -1443,11 +1443,11 @@ Keyword arguments are the following:
before test is executed even if they have `build_by_default : false`.
Since 0.46.0

- `protocol` specifies how the test results are parsed. For now
it must be `exitcode`, that is the executable's exit code is used
by the test harness to record the outcome of the test. For example
an exit code of zero indicates success. For more on the Meson test harness
protocol read [Unit Tests](Unit-tests.md). Since 0.50.0
- `protocol` specifies how the test results are parsed and can be one
of `exitcode` (the executable's exit code is used by the test harness
to record the outcome of the test) or `tap` ([Test Anything
Protocol](https://www.testanything.org/)). For more on the Meson test
harness protocol read [Unit Tests](Unit-tests.md). Since 0.50.0

Defined tests can be run in a backend-agnostic way by calling
`meson test` inside the build dir, or by using backend-specific
Expand Down
6 changes: 5 additions & 1 deletion docs/markdown/Unit-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ $ MESON_TESTTHREADS=5 ninja test

## Skipped tests and hard errors

Sometimes a test can only determine at runtime that it can not be run. The GNU standard approach in this case is to exit the program with error code 77. Meson will detect this and report these tests as skipped rather than failed. This behavior was added in version 0.37.0.
Sometimes a test can only determine at runtime that it can not be run.

For the default `exitcode` testing protocol, the GNU standard approach in this case is to exit the program with error code 77. Meson will detect this and report these tests as skipped rather than failed. This behavior was added in version 0.37.0.

For TAP-based tests, skipped tests should print a single line starting with `1..0 # SKIP`.

In addition, sometimes a test fails set up so that it should fail even if it is marked as an expected failure. The GNU standard approach in this case is to exit the program with error code 99. Again, Meson will detect this and report these tests as `ERROR`, ignoring the setting of `should_fail`. This behavior was added in version 0.50.0.

Expand Down
4 changes: 2 additions & 2 deletions mesonbuild/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3269,8 +3269,8 @@ def add_test(self, node, args, kwargs, is_base_test):
if not isinstance(timeout, int):
raise InterpreterException('Timeout must be an integer.')
protocol = kwargs.get('protocol', 'exitcode')
if protocol not in ('exitcode',):
raise InterpreterException('Protocol must be "exitcode".')
if protocol not in ('exitcode', 'tap'):
raise InterpreterException('Protocol must be "exitcode" or "tap".')
suite = []
prj = self.subproject if self.is_subproject() else self.build.project_name
for s in mesonlib.stringlistify(kwargs.get('suite', '')):
Expand Down
187 changes: 186 additions & 1 deletion mesonbuild/mtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
from mesonbuild.mesonlib import substring_is_in_list, MesonException
from mesonbuild import mlog

from collections import namedtuple
import io
import re
import tempfile
import time, datetime, multiprocessing, json
import concurrent.futures as conc
Expand Down Expand Up @@ -153,6 +156,150 @@ class TestResult(enum.Enum):
ERROR = 'ERROR'


class TAPParser(object):
Plan = namedtuple('Plan', ['count', 'late', 'skipped', 'explanation'])
Bailout = namedtuple('Bailout', ['message'])
Test = namedtuple('Test', ['number', 'name', 'result', 'explanation'])
Error = namedtuple('Error', ['message'])
Version = namedtuple('Version', ['version'])

_MAIN = 1
_AFTER_TEST = 2
_YAML = 3

_RE_BAILOUT = r'Bail out!\s*(.*)'
_RE_DIRECTIVE = r'(?i:\s*\#\s*(SKIP\S*|TODO)\b\s*(.*))?'
_RE_PLAN = r'1\.\.([0-9]+)' + _RE_DIRECTIVE
_RE_TEST = r'((?:not )?ok)\s*(?:([0-9]+)\s*)?([^#]*)' + _RE_DIRECTIVE
_RE_VERSION = r'TAP version ([0-9]+)'
_RE_YAML_START = r'(\s+)---.*'
_RE_YAML_END = r'\s+\.\.\.\s*'

def __init__(self, io):
self.io = io

def parse_test(self, ok, num, name, directive, explanation):
name = name.strip()
explanation = explanation.strip() if explanation else None
if directive is not None:
directive = directive.upper()
if directive == 'SKIP':
if ok:
yield self.Test(num, name, TestResult.SKIP, explanation)
return
elif directive == 'TODO':
yield self.Test(num, name, TestResult.UNEXPECTEDPASS if ok else TestResult.EXPECTEDFAIL, explanation)
return
else:
yield self.Error('invalid directive "%s"' % (directive,))

yield self.Test(num, name, TestResult.OK if ok else TestResult.FAIL, explanation)

def parse(self):
found_late_test = False
bailed_out = False
plan = None
lineno = 0
num_tests = 0
yaml_lineno = None
yaml_indent = None
state = self._MAIN
version = 12
while True:
lineno += 1
try:
line = next(self.io).rstrip()
except StopIteration:
break

# YAML blocks are only accepted after a test
if state == self._AFTER_TEST:
if version >= 13:
m = re.match(self._RE_YAML_START, line)
if m:
state = self._YAML
yaml_lineno = lineno
yaml_indent = m.group(1)
continue
state = self._MAIN

elif state == self._YAML:
if re.match(self._RE_YAML_END, line):
state = self._MAIN
continue
if line.startswith(yaml_indent):
continue
yield self.Error('YAML block not terminated (started on line %d)' % (yaml_lineno,))
state = self._MAIN

assert state == self._MAIN
if line.startswith('#'):
continue

m = re.match(self._RE_TEST, line)
if m:
if plan and plan.late and not found_late_test:
yield self.Error('unexpected test after late plan')
found_late_test = True
num_tests += 1
num = num_tests if m.group(2) is None else int(m.group(2))
if num != num_tests:
yield self.Error('out of order test numbers')
yield from self.parse_test(m.group(1) == 'ok', num,
m.group(3), m.group(4), m.group(5))
state = self._AFTER_TEST
continue

m = re.match(self._RE_PLAN, line)
if m:
if plan:
yield self.Error('more than one plan found')
else:
count = int(m.group(1))
skipped = (count == 0)
if m.group(2):
if m.group(2).upper().startswith('SKIP'):
if count > 0:
yield self.Error('invalid SKIP directive for plan')
skipped = True
else:
yield self.Error('invalid directive for plan')
plan = self.Plan(count=count, late=(num_tests > 0),
skipped=skipped, explanation=m.group(3))
yield plan
continue

m = re.match(self._RE_BAILOUT, line)
if m:
yield self.Bailout(m.group(1))
bailed_out = True
continue

m = re.match(self._RE_VERSION, line)
if m:
# The TAP version is only accepted as the first line
if lineno != 1:
yield self.Error('version number must be on the first line')
continue
version = int(m.group(1))
if version < 13:
yield self.Error('version number should be at least 13')
else:
yield self.Version(version=version)
continue

yield self.Error('unexpected input at line %d' % (lineno,))

if state == self._YAML:
yield self.Error('YAML block not terminated (started on line %d)' % (yaml_lineno,))

if not bailed_out and plan and num_tests != plan.count:
if num_tests < plan.count:
yield self.Error('Too few tests run (expected %d, got %d)' % (plan.count, num_tests))
else:
yield self.Error('Too many tests run (expected %d, got %d)' % (plan.count, num_tests))


class TestRun:
@staticmethod
def make_exitcode(test, returncode, duration, stdo, stde, cmd):
Expand All @@ -166,6 +313,41 @@ def make_exitcode(test, returncode, duration, stdo, stde, cmd):
res = TestResult.FAIL if bool(returncode) else TestResult.OK
return TestRun(test, res, returncode, duration, stdo, stde, cmd)

def make_tap(test, returncode, duration, stdo, stde, cmd):
res = None
num_tests = 0
failed = False
num_skipped = 0

for i in TAPParser(io.StringIO(stdo)).parse():
if isinstance(i, TAPParser.Bailout):
res = TestResult.ERROR
elif isinstance(i, TAPParser.Test):
if i.result == TestResult.SKIP:
num_skipped += 1
elif i.result in (TestResult.FAIL, TestResult.UNEXPECTEDPASS):
failed = True
num_tests += 1
elif isinstance(i, TAPParser.Error):
res = TestResult.ERROR
stde += '\nTAP parsing error: ' + i.message

if returncode != 0:
res = TestResult.ERROR
stde += '\n(test program exited with status code %d)' % (returncode,)

if res is None:
# Now determine the overall result of the test based on the outcome of the subcases
if num_skipped == num_tests:
# This includes the case where num_tests is zero
res = TestResult.SKIP
elif test.should_fail:
res = TestResult.EXPECTEDFAIL if failed else TestResult.UNEXPECTEDPASS
else:
res = TestResult.FAIL if failed else TestResult.OK

return TestRun(test, res, returncode, duration, stdo, stde, cmd)

def __init__(self, test, res, returncode, duration, stdo, stde, cmd):
assert isinstance(res, TestResult)
self.res = res
Expand Down Expand Up @@ -405,7 +587,10 @@ def preexec_fn():
if timed_out:
return TestRun(self.test, TestResult.TIMEOUT, p.returncode, duration, stdo, stde, cmd)
else:
return TestRun.make_exitcode(self.test, p.returncode, duration, stdo, stde, cmd)
if self.test.protocol == 'exitcode':
return TestRun.make_exitcode(self.test, p.returncode, duration, stdo, stde, cmd)
else:
return TestRun.make_tap(self.test, p.returncode, duration, stdo, stde, cmd)


class TestHarness:
Expand Down

0 comments on commit 5415b0c

Please sign in to comment.