testaferro 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- testaferro/__init__.py +83 -0
- testaferro/backend.py +65 -0
- testaferro/binfmt.py +109 -0
- testaferro/cache.py +21 -0
- testaferro/cpputest.py +128 -0
- testaferro/facade.py +227 -0
- testaferro/machines.py +400 -0
- testaferro/qemu.py +364 -0
- testaferro/suite.py +51 -0
- testaferro-0.1.0.dev0.dist-info/METADATA +183 -0
- testaferro-0.1.0.dev0.dist-info/RECORD +14 -0
- testaferro-0.1.0.dev0.dist-info/WHEEL +5 -0
- testaferro-0.1.0.dev0.dist-info/licenses/LICENSE +29 -0
- testaferro-0.1.0.dev0.dist-info/top_level.txt +1 -0
testaferro/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""testaferro: a pytest facade for remote (e.g. QEMU-guest) unit
|
|
4
|
+
tests.
|
|
5
|
+
|
|
6
|
+
A suite compiled for and running on a remote target surfaces as
|
|
7
|
+
ordinary pytest tests on the host. Hand guest_suite() a reference to
|
|
8
|
+
the suite executable:
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import testaferro
|
|
13
|
+
|
|
14
|
+
test_guest_case = testaferro.guest_suite(
|
|
15
|
+
Path(__file__).parent / "SUITE.EXE")
|
|
16
|
+
|
|
17
|
+
The executable is interrogated to select its platform (DOS programs
|
|
18
|
+
run in a QEMU guest; anything else is rejected), the
|
|
19
|
+
framework adapter defaults to testaferro.cpputest (`framework=`
|
|
20
|
+
overrides), and the runner's working state lives in
|
|
21
|
+
testaferro-managed disposable directories. Named test machines are
|
|
22
|
+
declared with config() or an optional per-project testaferro.ini; a
|
|
23
|
+
prebuilt Backend remains the custom escape hatch for callers that
|
|
24
|
+
need a different execution mechanism.
|
|
25
|
+
|
|
26
|
+
For many suites (and future parallel runs), open a session so the
|
|
27
|
+
boot image is specified once and all per-run state is swept together
|
|
28
|
+
— in pytest, from the consumer's conftest.py:
|
|
29
|
+
|
|
30
|
+
import testaferro
|
|
31
|
+
|
|
32
|
+
testaferro.start() # or start(boot_image=...)
|
|
33
|
+
|
|
34
|
+
def pytest_unconfigure(config):
|
|
35
|
+
testaferro.stop()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# eager: the facade's own imports are stdlib-only (pytest is loaded
|
|
39
|
+
# lazily inside it). start/stop delegate lazily instead, because
|
|
40
|
+
# importing testaferro.qemu pulls in reliquary.
|
|
41
|
+
from .facade import guest_suite # noqa: F401
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config(machine, platform=None, **options):
|
|
45
|
+
"""Declare a named reliquary test machine.
|
|
46
|
+
|
|
47
|
+
``platform`` is optional when the supplied ``machine_config`` or
|
|
48
|
+
``template`` declares it. Without a template, remaining options are
|
|
49
|
+
the blueprint's own machine fields. The declaration is reused
|
|
50
|
+
as a template; each guest session receives a fresh materialization.
|
|
51
|
+
The same declarations may be written in ``testaferro.ini`` (see
|
|
52
|
+
``load_config``).
|
|
53
|
+
"""
|
|
54
|
+
from .machines import configure
|
|
55
|
+
return configure(machine, platform=platform, **options)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(path=None):
|
|
59
|
+
"""Load named machines from a ``testaferro.ini``.
|
|
60
|
+
|
|
61
|
+
With ``path``, read that file. With ``path`` omitted, search
|
|
62
|
+
upward from the current directory. ``guest_suite()`` performs the
|
|
63
|
+
same search from its call site automatically.
|
|
64
|
+
"""
|
|
65
|
+
from .machines import load_config as load
|
|
66
|
+
return load(path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def start(boot_image=None):
|
|
70
|
+
"""Open a guest-test session: one boot-image choice (or the
|
|
71
|
+
downloaded default) serving every suite until stop(). Costs
|
|
72
|
+
nothing until a guest actually runs; an atexit failsafe sweeps
|
|
73
|
+
the session if stop() is never called."""
|
|
74
|
+
from . import qemu
|
|
75
|
+
qemu.start(boot_image=boot_image)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def stop(clear_downloads=False):
|
|
79
|
+
"""Close the session, sweeping its staged image and all per-run
|
|
80
|
+
state; safe without an active session. `clear_downloads=True`
|
|
81
|
+
also drops the cached default boot image."""
|
|
82
|
+
from . import qemu
|
|
83
|
+
qemu.stop(clear_downloads=clear_downloads)
|
testaferro/backend.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""The Backend seam: what the pytest facade needs from any remote
|
|
4
|
+
test target.
|
|
5
|
+
|
|
6
|
+
Any target that can list its tests and run one or all of them plugs
|
|
7
|
+
into the same facade, however it carries those operations out
|
|
8
|
+
underneath. Session hooks default to no-ops because one-shot backends
|
|
9
|
+
(e.g. a SuiteBackend whose runner boots per operation)
|
|
10
|
+
have no session to manage.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TestId:
|
|
21
|
+
__test__ = False # not a test case, despite the name
|
|
22
|
+
|
|
23
|
+
group: str
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def __str__(self):
|
|
27
|
+
return f"{self.group}.{self.name}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TestOutcome:
|
|
32
|
+
__test__ = False # not a test case, despite the name
|
|
33
|
+
|
|
34
|
+
group: str
|
|
35
|
+
name: str
|
|
36
|
+
passed: bool
|
|
37
|
+
file: str = ""
|
|
38
|
+
line: int = 0
|
|
39
|
+
message: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Backend(ABC):
|
|
43
|
+
def start_session(self):
|
|
44
|
+
"""Boot or attach to the remote target. Ready to accept
|
|
45
|
+
list/run calls immediately after this returns."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def list_tests(self) -> "list[TestId]":
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def run_test(self, group, name) -> TestOutcome:
|
|
53
|
+
"""Run one test. Raises LookupError if the target did not
|
|
54
|
+
run it (e.g. host and target test lists diverged)."""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def run_all(self) -> "list[TestOutcome]":
|
|
58
|
+
"""Used by the facade's batching logic when no host-side
|
|
59
|
+
filter is active - a single bulk operation is expected to be
|
|
60
|
+
cheaper than many individual run_test() calls, however the
|
|
61
|
+
backend chooses to implement that."""
|
|
62
|
+
|
|
63
|
+
def stop_session(self):
|
|
64
|
+
"""Tear down cleanly. Must be safe to call after a failed or
|
|
65
|
+
partial start_session()."""
|
testaferro/binfmt.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""Executable-format classification for platform dispatch.
|
|
4
|
+
|
|
5
|
+
`classify()` reads an executable's header and reports which platform
|
|
6
|
+
could run it, without importing any runner:
|
|
7
|
+
|
|
8
|
+
- ``Format("dos", ...)`` — a plain MZ program, or a headerless image
|
|
9
|
+
(`.com`-style raw code carries nothing to prove, so it passes
|
|
10
|
+
through for the guest itself to judge);
|
|
11
|
+
- ``Format(None, ...)`` — a provably unsupported binary: PE,
|
|
12
|
+
NE/LX/LE, ELF, Mach-O (including universal).
|
|
13
|
+
|
|
14
|
+
`kind` always carries the human-readable format-and-architecture
|
|
15
|
+
name ('a Windows x64 (PE)', 'an ELF x86-64 (Linux/BSD)', ...) for
|
|
16
|
+
error messages, and a future platform extends the classification by
|
|
17
|
+
claiming formats currently mapped to None. Stdlib-only: the facade's
|
|
18
|
+
dispatch and each platform binding's own guard share it without
|
|
19
|
+
pulling in a runner.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import collections
|
|
25
|
+
|
|
26
|
+
Format = collections.namedtuple("Format", ["platform", "kind"])
|
|
27
|
+
|
|
28
|
+
_DOS_MZ = Format("dos", "a DOS MZ")
|
|
29
|
+
_HEADERLESS = Format("dos", "a headerless (.com-style)")
|
|
30
|
+
|
|
31
|
+
# architecture names per format, keyed by each format's machine field
|
|
32
|
+
_ELF_MACHINES = {0x03: "x86", 0x28: "ARM", 0x3E: "x86-64",
|
|
33
|
+
0xB7: "ARM64", 0xF3: "RISC-V"}
|
|
34
|
+
_PE_MACHINES = {0x014C: "x86", 0x01C0: "ARM", 0x01C4: "ARM",
|
|
35
|
+
0x8664: "x64", 0xAA64: "ARM64"}
|
|
36
|
+
_MACHO_CPUTYPES = {7: "x86", 0x01000007: "x86-64",
|
|
37
|
+
12: "ARM", 0x0100000C: "ARM64"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _elf_format(header):
|
|
41
|
+
"""Kind string for an ELF image (Linux and the BSDs; most carry
|
|
42
|
+
the generic System V OS/ABI byte, so no OS is claimed)."""
|
|
43
|
+
arch = None
|
|
44
|
+
if len(header) >= 0x14:
|
|
45
|
+
order = "little" if header[5] == 1 else "big"
|
|
46
|
+
arch = _ELF_MACHINES.get(
|
|
47
|
+
int.from_bytes(header[0x12:0x14], order))
|
|
48
|
+
return (f"an ELF {arch} (Linux/BSD)" if arch
|
|
49
|
+
else "an ELF (Linux/BSD)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _macho_format(header):
|
|
53
|
+
"""Kind string for a Mach-O image, or None when the magic is
|
|
54
|
+
really something else's."""
|
|
55
|
+
magic = header[:4]
|
|
56
|
+
if magic in (b"\xca\xfe\xba\xbe", b"\xca\xfe\xba\xbf"):
|
|
57
|
+
# a universal binary's big-endian arch count is tiny; a Java
|
|
58
|
+
# class file shares the magic but puts its version there
|
|
59
|
+
if (len(header) >= 8
|
|
60
|
+
and int.from_bytes(header[4:8], "big") < 0x40):
|
|
61
|
+
return "a macOS universal (Mach-O)"
|
|
62
|
+
return None
|
|
63
|
+
order = "big" if magic[:2] == b"\xfe\xed" else "little"
|
|
64
|
+
arch = (_MACHO_CPUTYPES.get(int.from_bytes(header[4:8], order))
|
|
65
|
+
if len(header) >= 8 else None)
|
|
66
|
+
return (f"a macOS {arch} (Mach-O)" if arch
|
|
67
|
+
else "a macOS (Mach-O)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mz_extension_format(found):
|
|
71
|
+
"""The Format of the newer-format header an extended MZ file
|
|
72
|
+
points at through e_lfanew, or None when none is recognized
|
|
73
|
+
there. DOS runs none of these."""
|
|
74
|
+
if found.startswith(b"PE\0\0"):
|
|
75
|
+
arch = (_PE_MACHINES.get(int.from_bytes(found[4:6], "little"))
|
|
76
|
+
if len(found) >= 6 else None)
|
|
77
|
+
return Format(None, f"a Windows {arch} (PE)" if arch
|
|
78
|
+
else "a Windows (PE)")
|
|
79
|
+
for signature, kind in ((b"NE", "a 16-bit Windows or OS/2 (NE)"),
|
|
80
|
+
(b"LX", "an OS/2 (LX)"),
|
|
81
|
+
(b"LE", "a linear (LE)")):
|
|
82
|
+
if found.startswith(signature):
|
|
83
|
+
return Format(None, kind)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify(exe_path):
|
|
88
|
+
"""The Format of the referenced executable: which platform could
|
|
89
|
+
run it (`platform`) and its human-readable kind. Raises
|
|
90
|
+
FileNotFoundError for a missing file; attaches no meaning beyond
|
|
91
|
+
the header — a "dos" verdict means "nothing proves otherwise"."""
|
|
92
|
+
with open(exe_path, "rb") as f:
|
|
93
|
+
header = f.read(0x40)
|
|
94
|
+
if header[:4] == b"\x7fELF":
|
|
95
|
+
return Format(None, _elf_format(header))
|
|
96
|
+
if header[:4] in (b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
|
|
97
|
+
b"\xce\xfa\xed\xfe", b"\xcf\xfa\xed\xfe",
|
|
98
|
+
b"\xca\xfe\xba\xbe", b"\xca\xfe\xba\xbf"):
|
|
99
|
+
kind = _macho_format(header)
|
|
100
|
+
return _HEADERLESS if kind is None else Format(None, kind)
|
|
101
|
+
if header[:2] != b"MZ":
|
|
102
|
+
return _HEADERLESS
|
|
103
|
+
if len(header) < 0x40:
|
|
104
|
+
return _DOS_MZ
|
|
105
|
+
if int.from_bytes(header[0x18:0x1A], "little") < 0x40:
|
|
106
|
+
return _DOS_MZ
|
|
107
|
+
f.seek(int.from_bytes(header[0x3C:0x40], "little"))
|
|
108
|
+
found = f.read(8)
|
|
109
|
+
return _mz_extension_format(found) or _DOS_MZ
|
testaferro/cache.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""testaferro's durable filespace, shared by the guest bindings."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cache_root():
|
|
11
|
+
"""testaferro's own filespace: each guest binding's image cache
|
|
12
|
+
plus its disposable per-session runner homes (under runs/; stale
|
|
13
|
+
ones from killed processes can be deleted freely)."""
|
|
14
|
+
if os.name == "nt":
|
|
15
|
+
base = (os.environ.get("LOCALAPPDATA")
|
|
16
|
+
or os.path.join(os.path.expanduser("~"),
|
|
17
|
+
"AppData", "Local"))
|
|
18
|
+
else:
|
|
19
|
+
base = (os.environ.get("XDG_CACHE_HOME")
|
|
20
|
+
or os.path.join(os.path.expanduser("~"), ".cache"))
|
|
21
|
+
return os.path.join(base, "testaferro")
|
testaferro/cpputest.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""CppUTest framework adapter.
|
|
4
|
+
|
|
5
|
+
Knows CppUTest and nothing else: the argv that makes a suite
|
|
6
|
+
executable enumerate or run tests, and the grammar of the resulting
|
|
7
|
+
output. How and where the executable runs — the guest-OS aspect — is
|
|
8
|
+
deliberately not this module's business; the
|
|
9
|
+
two aspects compose in a SuiteBackend (testaferro.suite) or directly
|
|
10
|
+
at the call site:
|
|
11
|
+
|
|
12
|
+
log = run_guest_program(exe, args=cpputest.VERBOSE_ARGS)
|
|
13
|
+
results = cpputest.parse(log)
|
|
14
|
+
|
|
15
|
+
Output grammars follow CppUTest v4.0's own source (TestOutput.cpp,
|
|
16
|
+
TestRegistry.cpp): eclipse-style failure locations (the default),
|
|
17
|
+
'-ln' space-separated 'Group.Name' enumeration.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from .backend import TestId, TestOutcome
|
|
25
|
+
|
|
26
|
+
# Suite argv for each backend operation. VERBOSE_ARGS produces the
|
|
27
|
+
# run output parse()/parse_run() understand; LIST_ARGS produces the
|
|
28
|
+
# enumeration parse_list() understands.
|
|
29
|
+
VERBOSE_ARGS = "-v"
|
|
30
|
+
LIST_ARGS = "-ln"
|
|
31
|
+
|
|
32
|
+
# CppUTest verbose (-v) output. Test ids are 'Group.Name', matching
|
|
33
|
+
# the executable's own -ln enumeration format.
|
|
34
|
+
_RAN = re.compile(r"^(?:IGNORE_)?TEST\((\w+), (\w+)\)", re.M)
|
|
35
|
+
# Eclipse-style failure header: '<file>:<line>: error: Failure in
|
|
36
|
+
# TEST(<group>, <name>)'. When the failure is outside the test file,
|
|
37
|
+
# a second '<file>:<line>: error:' location line follows with the
|
|
38
|
+
# actual failure site; the tab-indented message lines come last.
|
|
39
|
+
_FAILED = re.compile(
|
|
40
|
+
r"^(.*):(\d+): error: Failure in TEST\((\w+), (\w+)\)$", re.M)
|
|
41
|
+
_LOCATION = re.compile(r"^(.*):(\d+): error:$")
|
|
42
|
+
_SUMMARY = re.compile(r"^(?:OK|Errors) \(.*\)", re.M)
|
|
43
|
+
_LIST_ID = re.compile(r"(\w+)\.(\w+)$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_argv():
|
|
47
|
+
"""Suite argv that enumerates tests in parse_list()'s format."""
|
|
48
|
+
return LIST_ARGS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_all_argv():
|
|
52
|
+
"""Suite argv that runs every test in parse_run()'s format."""
|
|
53
|
+
return VERBOSE_ARGS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_one_argv(group, name):
|
|
57
|
+
"""Suite argv that runs exactly one test in parse_run()'s
|
|
58
|
+
format ('-sg'/'-sn' are CppUTest's strict-match filters)."""
|
|
59
|
+
return f"{VERBOSE_ARGS} -sg {group} -sn {name}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_list(text):
|
|
63
|
+
"""Parse '-ln' enumeration output: space-separated 'Group.Name'
|
|
64
|
+
tokens. Returns a list of TestId."""
|
|
65
|
+
text = text.replace("\r\n", "\n")
|
|
66
|
+
ids = []
|
|
67
|
+
for token in text.split():
|
|
68
|
+
match = _LIST_ID.fullmatch(token)
|
|
69
|
+
if not match:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"not a CppUTest -ln test list:\n{text}")
|
|
72
|
+
ids.append(TestId(*match.groups()))
|
|
73
|
+
return ids
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _failures(text):
|
|
77
|
+
"""Map 'Group.Name' -> (file, line, message) for each failure
|
|
78
|
+
block in verbose output."""
|
|
79
|
+
lines = text.splitlines()
|
|
80
|
+
failures = {}
|
|
81
|
+
for match in _FAILED.finditer(text):
|
|
82
|
+
file, line, group, name = match.groups()
|
|
83
|
+
rest = lines[text.count("\n", 0, match.start()) + 1:]
|
|
84
|
+
if rest and _LOCATION.match(rest[0]):
|
|
85
|
+
# Failure outside the test file: the second location
|
|
86
|
+
# line carries the actual failure site.
|
|
87
|
+
file, line = _LOCATION.match(rest[0]).groups()
|
|
88
|
+
rest = rest[1:]
|
|
89
|
+
message = []
|
|
90
|
+
for text_line in rest:
|
|
91
|
+
if not text_line:
|
|
92
|
+
break
|
|
93
|
+
message.append(text_line.lstrip("\t"))
|
|
94
|
+
failures[f"{group}.{name}"] = (file, int(line), "\n".join(message))
|
|
95
|
+
return failures
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_run(text):
|
|
99
|
+
"""Parse verbose (-v) run output into a list of TestOutcome, in
|
|
100
|
+
output order. Raises ValueError when the output carries no
|
|
101
|
+
CppUTest summary line (the run died before finishing). DOS logs
|
|
102
|
+
arrive with CRLF line endings; both endings are accepted."""
|
|
103
|
+
text = text.replace("\r\n", "\n")
|
|
104
|
+
if not _SUMMARY.search(text):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"no CppUTest summary line in suite output:\n{text}")
|
|
107
|
+
failures = _failures(text)
|
|
108
|
+
outcomes = []
|
|
109
|
+
for group, name in _RAN.findall(text):
|
|
110
|
+
file, line, message = failures.get(f"{group}.{name}", ("", 0, ""))
|
|
111
|
+
outcomes.append(TestOutcome(
|
|
112
|
+
group=group, name=name,
|
|
113
|
+
passed=f"{group}.{name}" not in failures,
|
|
114
|
+
file=file, line=line, message=message))
|
|
115
|
+
return outcomes
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse(text):
|
|
119
|
+
"""Normalize CppUTest verbose output: return a dict with "ran" and
|
|
120
|
+
"failed" (sets of 'Group.Name' ids) and "summary" (the OK/Errors
|
|
121
|
+
line)."""
|
|
122
|
+
outcomes = parse_run(text)
|
|
123
|
+
return {
|
|
124
|
+
"ran": {f"{o.group}.{o.name}" for o in outcomes},
|
|
125
|
+
"failed": {f"{o.group}.{o.name}" for o in outcomes
|
|
126
|
+
if not o.passed},
|
|
127
|
+
"summary": _SUMMARY.search(text).group(0),
|
|
128
|
+
}
|
testaferro/facade.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""The pytest facade: surface a guest suite's tests as pytest items.
|
|
4
|
+
|
|
5
|
+
A consumer's test module hands over a reference to its suite
|
|
6
|
+
executable (public entry point: testaferro.guest_suite):
|
|
7
|
+
|
|
8
|
+
test_guest_case = testaferro.guest_suite(HERE / "SUITE.EXE")
|
|
9
|
+
|
|
10
|
+
The executable is interrogated to select the matching platform binding
|
|
11
|
+
(currently: DOS programs, run under QEMU); a provably unsupported
|
|
12
|
+
binary is rejected with a clear error. Alternatively a prebuilt
|
|
13
|
+
Backend may be passed in place of the path — the custom execution
|
|
14
|
+
escape hatch and test seam for this facade.
|
|
15
|
+
|
|
16
|
+
Each of the suite's tests becomes one pytest item, so pytest's own
|
|
17
|
+
selection (-k, node ids) drives what actually runs remotely. When the
|
|
18
|
+
whole suite is selected, the facade batches everything into a single
|
|
19
|
+
run_all(); a narrowed selection falls back to individual run_test()
|
|
20
|
+
calls. Remote failures are replayed with the
|
|
21
|
+
guest side's original file/line/message rather than a traceback into
|
|
22
|
+
this facade.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import importlib
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from . import binfmt
|
|
31
|
+
from .backend import TestId
|
|
32
|
+
|
|
33
|
+
# platform name -> binding module (a sibling of this one), imported only
|
|
34
|
+
# when dispatch selects it
|
|
35
|
+
_PLATFORM_BINDINGS = {"dos": "qemu"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ResultBroker:
|
|
39
|
+
"""Lazily fetch outcomes for one suite, deciding between one
|
|
40
|
+
batched run_all() and per-test run_test() calls from how much of
|
|
41
|
+
the suite pytest actually selected. pytest-free on purpose."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, backend, ids):
|
|
44
|
+
self._backend = backend
|
|
45
|
+
self._ids = list(ids)
|
|
46
|
+
self._batch = None
|
|
47
|
+
self._single = {}
|
|
48
|
+
|
|
49
|
+
def outcome(self, test_id, selected_ids):
|
|
50
|
+
if set(selected_ids) == set(self._ids):
|
|
51
|
+
if self._batch is None:
|
|
52
|
+
self._batch = {TestId(o.group, o.name): o
|
|
53
|
+
for o in self._backend.run_all()}
|
|
54
|
+
if test_id not in self._batch:
|
|
55
|
+
raise LookupError(
|
|
56
|
+
f"target did not run test {test_id} "
|
|
57
|
+
"(host and target test lists out of sync?)")
|
|
58
|
+
return self._batch[test_id]
|
|
59
|
+
if test_id not in self._single:
|
|
60
|
+
self._single[test_id] = self._backend.run_test(
|
|
61
|
+
test_id.group, test_id.name)
|
|
62
|
+
return self._single[test_id]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def guest_suite(target, framework=None, enumerator=None,
|
|
66
|
+
platform=None, machine=None, **machine_options):
|
|
67
|
+
"""Return a pytest test function with one parameterized item per
|
|
68
|
+
test in the referenced suite. Assign it to a test_-prefixed
|
|
69
|
+
module attribute:
|
|
70
|
+
|
|
71
|
+
test_guest_case = testaferro.guest_suite(HERE / "SUITE.EXE")
|
|
72
|
+
|
|
73
|
+
`target` is a path to the suite executable (interrogated to pick
|
|
74
|
+
the guest backend) or a prebuilt Backend. The keyword options
|
|
75
|
+
apply to the path form only: `framework` overrides the framework
|
|
76
|
+
adapter, `enumerator` supplies a faster host-side source of the
|
|
77
|
+
test list, `platform` chooses an OS platform when the executable's
|
|
78
|
+
own format should not decide, and `machine` chooses a named test
|
|
79
|
+
machine declared with testaferro.config() or testaferro.ini
|
|
80
|
+
(searched upward from this call site). Any further keyword is
|
|
81
|
+
machine-specific and validated by the selected binding: today,
|
|
82
|
+
`boot_image=` or `machine_config=` for DOS.
|
|
83
|
+
|
|
84
|
+
Enumeration (backend.list_tests()) happens in its own session at
|
|
85
|
+
import/collection time. A second session starts lazily when the
|
|
86
|
+
first selected item runs and is cleaned up when pytest finishes.
|
|
87
|
+
|
|
88
|
+
The returned function is re-homed to the caller's file and call
|
|
89
|
+
line, so IDE per-item actions that key on source location (run
|
|
90
|
+
one item, jump to source) resolve to the consumer's module
|
|
91
|
+
rather than this facade.
|
|
92
|
+
"""
|
|
93
|
+
import inspect
|
|
94
|
+
|
|
95
|
+
import pytest
|
|
96
|
+
|
|
97
|
+
frame = inspect.currentframe()
|
|
98
|
+
caller = None if frame is None else frame.f_back
|
|
99
|
+
call_site = (None if caller is None else
|
|
100
|
+
(caller.f_code.co_filename, caller.f_lineno))
|
|
101
|
+
del frame, caller
|
|
102
|
+
|
|
103
|
+
options = {name: value
|
|
104
|
+
for name, value in (("framework", framework),
|
|
105
|
+
("enumerator", enumerator))
|
|
106
|
+
if value is not None}
|
|
107
|
+
options.update(machine_options)
|
|
108
|
+
if isinstance(target, (str, os.PathLike)):
|
|
109
|
+
search_from = (None if call_site is None
|
|
110
|
+
else os.path.dirname(call_site[0]))
|
|
111
|
+
backend = _dispatched_backend(
|
|
112
|
+
target, platform, machine, options,
|
|
113
|
+
search_from=search_from)
|
|
114
|
+
else:
|
|
115
|
+
given = sorted(options)
|
|
116
|
+
if platform is not None:
|
|
117
|
+
given.append("platform")
|
|
118
|
+
if machine is not None:
|
|
119
|
+
given.append("machine")
|
|
120
|
+
if given:
|
|
121
|
+
raise TypeError(
|
|
122
|
+
"keyword options apply only when passing an "
|
|
123
|
+
"executable path; a prebuilt Backend carries its own "
|
|
124
|
+
"configuration: " + ", ".join(given))
|
|
125
|
+
backend = target
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
backend.start_session()
|
|
129
|
+
ids = list(backend.list_tests())
|
|
130
|
+
finally:
|
|
131
|
+
backend.stop_session()
|
|
132
|
+
broker = ResultBroker(backend, ids)
|
|
133
|
+
execution_session_started = False
|
|
134
|
+
|
|
135
|
+
def start_execution_session(config):
|
|
136
|
+
nonlocal execution_session_started
|
|
137
|
+
if execution_session_started:
|
|
138
|
+
return
|
|
139
|
+
try:
|
|
140
|
+
backend.start_session()
|
|
141
|
+
config.add_cleanup(backend.stop_session)
|
|
142
|
+
except BaseException:
|
|
143
|
+
backend.stop_session()
|
|
144
|
+
raise
|
|
145
|
+
execution_session_started = True
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize("guest_test", ids, ids=_item_id)
|
|
148
|
+
def run_guest_test(guest_test, request):
|
|
149
|
+
start_execution_session(request.config)
|
|
150
|
+
try:
|
|
151
|
+
outcome = broker.outcome(guest_test,
|
|
152
|
+
_selected_ids(request, broker))
|
|
153
|
+
except LookupError as error:
|
|
154
|
+
pytest.fail(str(error), pytrace=False)
|
|
155
|
+
if not outcome.passed:
|
|
156
|
+
where = (f"{outcome.file}:{outcome.line}: "
|
|
157
|
+
if outcome.file else "")
|
|
158
|
+
pytest.fail(f"guest test failed: {where}{outcome.message}",
|
|
159
|
+
pytrace=False)
|
|
160
|
+
|
|
161
|
+
if call_site is not None:
|
|
162
|
+
run_guest_test.__code__ = run_guest_test.__code__.replace(
|
|
163
|
+
co_filename=call_site[0], co_firstlineno=call_site[1])
|
|
164
|
+
run_guest_test._testaferro_broker = broker
|
|
165
|
+
return run_guest_test
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _dispatched_backend(target, platform, machine, options,
|
|
169
|
+
search_from=None):
|
|
170
|
+
"""Build the suite backend for an executable path: select the
|
|
171
|
+
platform binding — from the caller's machine/platform choice or
|
|
172
|
+
the executable's own format — import it, and hand it its options."""
|
|
173
|
+
from . import machines
|
|
174
|
+
|
|
175
|
+
machines.load_config(search_from=search_from)
|
|
176
|
+
if platform is not None:
|
|
177
|
+
platform = str(platform).lower()
|
|
178
|
+
if platform not in _PLATFORM_BINDINGS:
|
|
179
|
+
raise ValueError(f"unsupported platform {platform!r}; "
|
|
180
|
+
"supported: "
|
|
181
|
+
+ ", ".join(sorted(_PLATFORM_BINDINGS)))
|
|
182
|
+
fmt = binfmt.classify(target)
|
|
183
|
+
if fmt.platform is None and platform is None and machine is None:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"{os.path.basename(os.fspath(target))} is "
|
|
186
|
+
f"{fmt.kind} executable; no supported platform can run it")
|
|
187
|
+
selected = machines.select(machine, platform, fmt.platform)
|
|
188
|
+
if selected is not None:
|
|
189
|
+
_, machine_config = selected
|
|
190
|
+
if "machine_config" in options:
|
|
191
|
+
raise TypeError(
|
|
192
|
+
"machine_config cannot be combined with a named machine")
|
|
193
|
+
selected_platform = machine_config.platform
|
|
194
|
+
options["machine_config"] = machine_config
|
|
195
|
+
else:
|
|
196
|
+
selected_platform = platform if platform is not None else fmt.platform
|
|
197
|
+
if selected_platform not in _PLATFORM_BINDINGS:
|
|
198
|
+
raise ValueError(f"unsupported platform {selected_platform!r}; "
|
|
199
|
+
"supported: "
|
|
200
|
+
+ ", ".join(sorted(_PLATFORM_BINDINGS)))
|
|
201
|
+
binding = importlib.import_module(
|
|
202
|
+
"." + _PLATFORM_BINDINGS[selected_platform], __package__)
|
|
203
|
+
try:
|
|
204
|
+
return binding.suite_backend(target, **options)
|
|
205
|
+
except TypeError as error:
|
|
206
|
+
if "unexpected keyword argument" not in str(error):
|
|
207
|
+
raise
|
|
208
|
+
raise TypeError(f"{error} — options are machine-specific; "
|
|
209
|
+
f"the selected platform is "
|
|
210
|
+
f"{selected_platform!r}") from None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _item_id(test_id):
|
|
214
|
+
"""The bracketed id for one guest test item. A dash join, not
|
|
215
|
+
str(TestId): a dot inside a parametrize id breaks IDE
|
|
216
|
+
tree→target mapping (dots are hierarchy separators there),
|
|
217
|
+
turning run-this-item into run-the-whole-file."""
|
|
218
|
+
return f"{test_id.group}-{test_id.name}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _selected_ids(request, broker):
|
|
222
|
+
"""The suite's test ids that survived pytest's selection —
|
|
223
|
+
exactly the items pytest is going to run this session."""
|
|
224
|
+
return [item.callspec.params["guest_test"]
|
|
225
|
+
for item in request.session.items
|
|
226
|
+
if getattr(item.function, "_testaferro_broker", None)
|
|
227
|
+
is broker]
|