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/qemu.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""The QEMU/DOS guest binding.
|
|
4
|
+
|
|
5
|
+
`suite_backend()` guards the door with `binfmt.classify()`: a DOS
|
|
6
|
+
program — plain MZ or a headerless/.com image — yields a
|
|
7
|
+
QemuSuiteBackend; anything else is rejected before any guest work,
|
|
8
|
+
with the format and architecture named. The framework adapter
|
|
9
|
+
defaults to testaferro.cpputest.
|
|
10
|
+
|
|
11
|
+
QemuSuiteBackend drives a reliquary machine on the caller's behalf.
|
|
12
|
+
Each facade session gets a fresh, disposable reliquary home under
|
|
13
|
+
testaferro's cache directory (LOCALAPPDATA or XDG_CACHE_HOME): the
|
|
14
|
+
declaration is written there as a blueprint, reliquary creates and
|
|
15
|
+
boots one machine from it, and every guest run is one `reliquary.exec`
|
|
16
|
+
against that machine. The suite executable reaches the guest on a
|
|
17
|
+
host-directory (hostdir) work drive testaferro adds to the blueprint and
|
|
18
|
+
stages before boot. Zero configuration is seeded from the
|
|
19
|
+
caller-supplied `boot_image` or a cached FreeDOS image.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import atexit
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import tempfile
|
|
29
|
+
|
|
30
|
+
import reliquary
|
|
31
|
+
|
|
32
|
+
from . import binfmt
|
|
33
|
+
from . import cache
|
|
34
|
+
from . import cpputest
|
|
35
|
+
from .suite import SuiteBackend
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# The blueprint name testaferro writes into each session's private
|
|
39
|
+
# asset root, and the machine created from it.
|
|
40
|
+
_BLUEPRINT_NAME = "testaferro"
|
|
41
|
+
# The host-directory drive carrying the suite executable to the guest.
|
|
42
|
+
_WORK_MEDIA_NAME = "testaferro-work"
|
|
43
|
+
_BOOT_MEDIA_NAME = "testaferro-boot"
|
|
44
|
+
_DEFAULT_MEMORY = "32M"
|
|
45
|
+
# Seconds one guest command may take before reliquary gives up.
|
|
46
|
+
_DEFAULT_TIMEOUT = 120
|
|
47
|
+
_HDD_SLOTS = 4
|
|
48
|
+
|
|
49
|
+
# FreeDOS 1.4's FloppyEdition, from which x86BOOT.img (a ready-to-boot
|
|
50
|
+
# 1.44M floppy image) is extracted; a reliquary media definition,
|
|
51
|
+
# fetched and hash-verified through reliquary.fetch_media().
|
|
52
|
+
_FREEDOS_FLOPPY_MEDIA_NAME = "freedos-boot-floppy"
|
|
53
|
+
_FREEDOS_FLOPPY_MEDIA_DEFINITION = [
|
|
54
|
+
{
|
|
55
|
+
"type": "media",
|
|
56
|
+
"name": "freedos-floppy-edition",
|
|
57
|
+
"location": "https://download.freedos.org/1.4/FD14-FloppyEdition.zip",
|
|
58
|
+
"sha256": ("45b1fa7c52dd996c3bfa5e352ffcd410781b952a6ad629f"
|
|
59
|
+
"15a4c9ec4bbaefc5a"),
|
|
60
|
+
"children": [
|
|
61
|
+
{
|
|
62
|
+
"type": "media",
|
|
63
|
+
"name": _FREEDOS_FLOPPY_MEDIA_NAME,
|
|
64
|
+
"path": "144m/x86BOOT.img",
|
|
65
|
+
"sha256": ("552f7cbb0625960c050a3e682a4d2121cc2ffdfa9dc9d59"
|
|
66
|
+
"2ccd49edf179333dc"),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def suite_backend(exe_path, framework=cpputest, enumerator=None,
|
|
74
|
+
boot_image=None, machine_config=None):
|
|
75
|
+
"""Interrogate the referenced suite executable and return the
|
|
76
|
+
backend matching its format — a QemuSuiteBackend for a DOS
|
|
77
|
+
program. Raises FileNotFoundError for a missing file and
|
|
78
|
+
ValueError for a provably non-DOS executable (e.g. the suite's
|
|
79
|
+
host build passed by mistake)."""
|
|
80
|
+
exe_path = os.fspath(exe_path)
|
|
81
|
+
fmt = binfmt.classify(exe_path)
|
|
82
|
+
if fmt.platform != "dos":
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"{os.path.basename(exe_path)} is {fmt.kind} executable; "
|
|
85
|
+
"only DOS guest suites are supported")
|
|
86
|
+
if machine_config is not None:
|
|
87
|
+
from .machines import _coerce_machine_config
|
|
88
|
+
|
|
89
|
+
machine_config = _coerce_machine_config(machine_config)
|
|
90
|
+
if machine_config.platform != "dos":
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"the QEMU/DOS binding requires a DOS machine config, "
|
|
93
|
+
f"not {machine_config.platform!r}")
|
|
94
|
+
if boot_image is not None and machine_config is not None:
|
|
95
|
+
raise TypeError("boot_image and machine_config cannot be combined")
|
|
96
|
+
return QemuSuiteBackend(exe_path, framework=framework,
|
|
97
|
+
enumerator=enumerator,
|
|
98
|
+
boot_image=boot_image,
|
|
99
|
+
machine_config=machine_config)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# The active testaferro session opened by start(), or None: its
|
|
103
|
+
# disposable directory (holding the staged boot image and every run
|
|
104
|
+
# home) plus the recorded image choice, staged lazily on first use.
|
|
105
|
+
_session = None
|
|
106
|
+
|
|
107
|
+
# Backends holding a booted machine right now. A machine outlives the
|
|
108
|
+
# call that started it, so an interpreter that goes down between
|
|
109
|
+
# start_session() and stop_session() would otherwise leave the guest
|
|
110
|
+
# running — and sweeping its home would pull the disk out from under
|
|
111
|
+
# it. Every exit path stops machines before deleting anything.
|
|
112
|
+
_running = set()
|
|
113
|
+
_sweep_registered = False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _machine_started(backend):
|
|
117
|
+
"""Record a backend whose machine is now running."""
|
|
118
|
+
global _sweep_registered
|
|
119
|
+
_running.add(backend)
|
|
120
|
+
if not _sweep_registered:
|
|
121
|
+
atexit.register(_stop_running_machines)
|
|
122
|
+
_sweep_registered = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _stop_running_machines():
|
|
126
|
+
"""Stop every machine still running, best effort.
|
|
127
|
+
|
|
128
|
+
Called before any sweep and again at interpreter exit. One
|
|
129
|
+
backend failing to stop must not strand the others, so failures
|
|
130
|
+
are swallowed here; the machine's own home is removed regardless.
|
|
131
|
+
"""
|
|
132
|
+
for backend in list(_running):
|
|
133
|
+
try:
|
|
134
|
+
backend.stop_session()
|
|
135
|
+
except Exception:
|
|
136
|
+
_running.discard(backend)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def start(boot_image=None):
|
|
140
|
+
"""Open a testaferro session: one boot-image choice serving every
|
|
141
|
+
suite until stop(). The image itself — `boot_image` or the cached
|
|
142
|
+
default — is staged lazily on the first guest use, so calling
|
|
143
|
+
this from a conftest costs nothing when no guest test runs. An
|
|
144
|
+
atexit failsafe sweeps the session if stop() is never called."""
|
|
145
|
+
global _session
|
|
146
|
+
if _session is not None:
|
|
147
|
+
raise RuntimeError("a testaferro session is already active")
|
|
148
|
+
root = os.path.join(cache.cache_root(), "sessions")
|
|
149
|
+
os.makedirs(root, exist_ok=True)
|
|
150
|
+
_session = {
|
|
151
|
+
"dir": tempfile.mkdtemp(prefix="session-", dir=root),
|
|
152
|
+
"boot_image": (None if boot_image is None
|
|
153
|
+
else os.fspath(boot_image)),
|
|
154
|
+
}
|
|
155
|
+
# failsafe: sweep at interpreter exit if the caller never calls
|
|
156
|
+
# stop(); an explicit stop() unregisters this again
|
|
157
|
+
atexit.register(stop)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def stop(clear_downloads=False):
|
|
161
|
+
"""Close the session opened by start(), sweeping its whole area —
|
|
162
|
+
staged image and every run home. Safe to call with no session
|
|
163
|
+
active. `clear_downloads=True` also removes the cached default
|
|
164
|
+
boot image, forcing a fresh download next time."""
|
|
165
|
+
global _session
|
|
166
|
+
atexit.unregister(stop)
|
|
167
|
+
# Machines first: the run homes about to be swept are the disks
|
|
168
|
+
# those guests are running from.
|
|
169
|
+
_stop_running_machines()
|
|
170
|
+
if _session is not None:
|
|
171
|
+
shutil.rmtree(_session["dir"], ignore_errors=True)
|
|
172
|
+
_session = None
|
|
173
|
+
if clear_downloads:
|
|
174
|
+
cached = os.path.join(cache.cache_root(), "boot.img")
|
|
175
|
+
for path in (cached, cached + ".part"):
|
|
176
|
+
if os.path.exists(path):
|
|
177
|
+
os.remove(path)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _session_image():
|
|
181
|
+
"""The active session's staged boot image, staging it on first
|
|
182
|
+
use from the recorded choice or the cached default."""
|
|
183
|
+
image = os.path.join(_session["dir"], "boot.img")
|
|
184
|
+
if not os.path.exists(image):
|
|
185
|
+
shutil.copy(_session["boot_image"] or _cached_default_image(),
|
|
186
|
+
image)
|
|
187
|
+
return image
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cached_default_image():
|
|
191
|
+
"""testaferro's cached copy of the default boot image: FreeDOS
|
|
192
|
+
1.4's floppy-edition boot floppy, fetched and hash-verified
|
|
193
|
+
through reliquary.fetch_media() on first use."""
|
|
194
|
+
cached = os.path.join(cache.cache_root(), "boot.img")
|
|
195
|
+
if not os.path.exists(cached):
|
|
196
|
+
os.makedirs(cache.cache_root(), exist_ok=True)
|
|
197
|
+
with tempfile.TemporaryDirectory(
|
|
198
|
+
prefix="download-", dir=cache.cache_root()) as home:
|
|
199
|
+
assets = os.path.join(home, "assets")
|
|
200
|
+
os.makedirs(assets)
|
|
201
|
+
definition = os.path.join(assets, "freedos-floppy.rlqb")
|
|
202
|
+
with open(definition, "w", encoding="utf-8") as handle:
|
|
203
|
+
json.dump(_FREEDOS_FLOPPY_MEDIA_DEFINITION, handle)
|
|
204
|
+
payload = reliquary.fetch_media(
|
|
205
|
+
_FREEDOS_FLOPPY_MEDIA_NAME,
|
|
206
|
+
_context(home, assets))
|
|
207
|
+
shutil.copy(payload, cached + ".part")
|
|
208
|
+
os.replace(cached + ".part", cached)
|
|
209
|
+
return cached
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _context(home, assets):
|
|
213
|
+
"""A reliquary context pinned to one disposable testaferro home.
|
|
214
|
+
|
|
215
|
+
Dir-mode assets keep the resolution hermetic: only what testaferro
|
|
216
|
+
wrote for this run, never the user's own reliquary home or the
|
|
217
|
+
built-in codex.
|
|
218
|
+
"""
|
|
219
|
+
return reliquary.Context(home=home,
|
|
220
|
+
cache=os.path.join(home, "cache"),
|
|
221
|
+
assets=assets)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _work_drive(drives):
|
|
225
|
+
"""Place testaferro's work drive and name the letter DOS gives it.
|
|
226
|
+
|
|
227
|
+
reliquary assigns DOS letters from declared facts alone: floppies
|
|
228
|
+
take A:/B: by slot and hard disks C: onward in slot order. The
|
|
229
|
+
work drive takes the lowest free disk slot, so its letter is its
|
|
230
|
+
own position among the declared disks.
|
|
231
|
+
"""
|
|
232
|
+
used = sorted({int(key[len("hdd"):] or 0)
|
|
233
|
+
for key in drives if key.startswith("hdd")})
|
|
234
|
+
free = [slot for slot in range(_HDD_SLOTS) if slot not in used]
|
|
235
|
+
if not free:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"the machine declares every disk slot; testaferro needs one "
|
|
238
|
+
"free slot for the work drive that carries the suite "
|
|
239
|
+
"executable into the guest")
|
|
240
|
+
slot = free[0]
|
|
241
|
+
letter = chr(ord("C") + sorted(used + [slot]).index(slot))
|
|
242
|
+
return f"hdd{slot}", letter
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class QemuSuiteBackend(SuiteBackend):
|
|
246
|
+
def __init__(self, exe_path, framework=cpputest, enumerator=None,
|
|
247
|
+
boot_image=None, machine_config=None):
|
|
248
|
+
self._boot_image = (None if boot_image is None
|
|
249
|
+
else os.fspath(boot_image))
|
|
250
|
+
self._home = None
|
|
251
|
+
self._ctx = None
|
|
252
|
+
self._machine = None
|
|
253
|
+
self._letter = None
|
|
254
|
+
self._machine_config = machine_config
|
|
255
|
+
self._timeout = (
|
|
256
|
+
_DEFAULT_TIMEOUT if machine_config is None
|
|
257
|
+
or machine_config.timeout is None else machine_config.timeout)
|
|
258
|
+
super().__init__(os.fspath(exe_path), run=self._run_in_guest,
|
|
259
|
+
framework=framework, enumerator=enumerator)
|
|
260
|
+
|
|
261
|
+
def start_session(self):
|
|
262
|
+
area = _session["dir"] if _session else cache.cache_root()
|
|
263
|
+
runs = os.path.join(area, "runs")
|
|
264
|
+
os.makedirs(runs, exist_ok=True)
|
|
265
|
+
self._home = tempfile.mkdtemp(prefix="run-", dir=runs)
|
|
266
|
+
assets = os.path.join(self._home, "assets")
|
|
267
|
+
work = os.path.join(self._home, "work")
|
|
268
|
+
os.makedirs(assets)
|
|
269
|
+
os.makedirs(work)
|
|
270
|
+
# the backend snapshots the host directory when the drive is
|
|
271
|
+
# attached, so the executable is staged before the machine
|
|
272
|
+
# boots, not on the first run.
|
|
273
|
+
shutil.copy2(self._exe, os.path.join(work, self._program()))
|
|
274
|
+
|
|
275
|
+
document, self._letter = self._blueprint(work)
|
|
276
|
+
with open(os.path.join(assets, _BLUEPRINT_NAME + ".rlqb"),
|
|
277
|
+
"w", encoding="utf-8") as handle:
|
|
278
|
+
json.dump(document, handle)
|
|
279
|
+
self._ctx = _context(self._home, assets)
|
|
280
|
+
try:
|
|
281
|
+
self._machine = reliquary.create_machine(
|
|
282
|
+
_BLUEPRINT_NAME, context=self._ctx)
|
|
283
|
+
_machine_started(self)
|
|
284
|
+
reliquary.start_machine(self._machine, context=self._ctx)
|
|
285
|
+
except BaseException:
|
|
286
|
+
self.stop_session()
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
def stop_session(self):
|
|
290
|
+
if self._home is None:
|
|
291
|
+
return
|
|
292
|
+
_running.discard(self)
|
|
293
|
+
try:
|
|
294
|
+
if self._machine is not None:
|
|
295
|
+
reliquary.stop_machine(self._machine,
|
|
296
|
+
context=self._ctx)
|
|
297
|
+
finally:
|
|
298
|
+
# The machine's own cache lives under this home, so the
|
|
299
|
+
# sweep takes the whole run with it.
|
|
300
|
+
shutil.rmtree(self._home, ignore_errors=True)
|
|
301
|
+
self._home = None
|
|
302
|
+
self._ctx = None
|
|
303
|
+
self._machine = None
|
|
304
|
+
self._letter = None
|
|
305
|
+
|
|
306
|
+
def _program(self):
|
|
307
|
+
"""The suite's guest-side command name."""
|
|
308
|
+
return os.path.basename(self._exe)
|
|
309
|
+
|
|
310
|
+
def _run_in_guest(self, exe_path, args):
|
|
311
|
+
if self._home is None:
|
|
312
|
+
raise RuntimeError("no active session: guest runs happen "
|
|
313
|
+
"between start_session and stop_session")
|
|
314
|
+
command = f"{self._letter}:\\{self._program()}"
|
|
315
|
+
if args:
|
|
316
|
+
command += " " + " ".join(args)
|
|
317
|
+
rows = reliquary.exec(command, machine=self._machine,
|
|
318
|
+
context=self._ctx,
|
|
319
|
+
timeout=self._timeout)
|
|
320
|
+
return "\n".join(rows) + "\n"
|
|
321
|
+
|
|
322
|
+
def _blueprint(self, work):
|
|
323
|
+
"""The blueprint document for this backend session.
|
|
324
|
+
|
|
325
|
+
The declaration (or the zero-configuration default) plus
|
|
326
|
+
testaferro's own work drive, which is how the suite executable
|
|
327
|
+
reaches the guest. Every drive is authored JSON passed through
|
|
328
|
+
to reliquary, which owns materialization: a declaration stays
|
|
329
|
+
a template because reliquary materializes a fresh machine from
|
|
330
|
+
it each session.
|
|
331
|
+
"""
|
|
332
|
+
spec = self._machine_config
|
|
333
|
+
fields = dict(spec.fields) if spec is not None else {}
|
|
334
|
+
fields.setdefault("platform", "dos")
|
|
335
|
+
fields.setdefault("memory", _DEFAULT_MEMORY)
|
|
336
|
+
drives = dict(fields.get("drives") or {})
|
|
337
|
+
if not drives:
|
|
338
|
+
drives["floppy0"] = {
|
|
339
|
+
"type": "media",
|
|
340
|
+
"name": _BOOT_MEDIA_NAME,
|
|
341
|
+
"location": {"local": self._boot_media_path()},
|
|
342
|
+
"materialize": "use",
|
|
343
|
+
}
|
|
344
|
+
fields.setdefault("boot", ["floppy0"])
|
|
345
|
+
key, letter = _work_drive(drives)
|
|
346
|
+
drives[key] = {
|
|
347
|
+
"type": "media",
|
|
348
|
+
"name": _WORK_MEDIA_NAME,
|
|
349
|
+
"location": {"local": work},
|
|
350
|
+
"materialize": "use",
|
|
351
|
+
}
|
|
352
|
+
fields["drives"] = drives
|
|
353
|
+
fields["type"] = "machine"
|
|
354
|
+
fields["name"] = _BLUEPRINT_NAME
|
|
355
|
+
media = list(spec.media) if spec is not None else []
|
|
356
|
+
return [fields, *media], letter
|
|
357
|
+
|
|
358
|
+
def _boot_media_path(self):
|
|
359
|
+
"""The image the zero-configuration machine boots: this
|
|
360
|
+
backend's own choice, the session's staged image, or the
|
|
361
|
+
cached default."""
|
|
362
|
+
if self._boot_image is not None:
|
|
363
|
+
return self._boot_image
|
|
364
|
+
return _session_image() if _session else _cached_default_image()
|
testaferro/suite.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
"""SuiteBackend: internal execution × framework composition.
|
|
4
|
+
|
|
5
|
+
The reliquary-backed platform binding supplies the execution callable;
|
|
6
|
+
the framework adapter exposes list_argv/run_all_argv/run_one_argv and
|
|
7
|
+
parse_list/parse_run (e.g. testaferro.cpputest). Neither aspect knows
|
|
8
|
+
the other exists. The callable remains an internal testing and
|
|
9
|
+
composition seam, not a public runner contract.
|
|
10
|
+
|
|
11
|
+
backend = SuiteBackend("VRING16.EXE",
|
|
12
|
+
run=run_guest_program,
|
|
13
|
+
framework=cpputest)
|
|
14
|
+
|
|
15
|
+
The optional `enumerator` replaces guest enumeration with a faster
|
|
16
|
+
source of the same list — e.g. a host-built twin executable — without
|
|
17
|
+
changing where the tests actually run.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .backend import Backend, TestId, TestOutcome
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SuiteBackend(Backend):
|
|
26
|
+
def __init__(self, exe_path, run, framework, enumerator=None):
|
|
27
|
+
self._exe = exe_path
|
|
28
|
+
self._run = run
|
|
29
|
+
self._framework = framework
|
|
30
|
+
self._enumerator = enumerator
|
|
31
|
+
|
|
32
|
+
def list_tests(self) -> "list[TestId]":
|
|
33
|
+
if self._enumerator is not None:
|
|
34
|
+
return self._enumerator()
|
|
35
|
+
return self._framework.parse_list(
|
|
36
|
+
self._run(self._exe, self._framework.list_argv()))
|
|
37
|
+
|
|
38
|
+
def run_test(self, group, name) -> TestOutcome:
|
|
39
|
+
outcomes = self._framework.parse_run(
|
|
40
|
+
self._run(self._exe,
|
|
41
|
+
self._framework.run_one_argv(group, name)))
|
|
42
|
+
for outcome in outcomes:
|
|
43
|
+
if (outcome.group, outcome.name) == (group, name):
|
|
44
|
+
return outcome
|
|
45
|
+
raise LookupError(
|
|
46
|
+
f"target did not run test {group}.{name} "
|
|
47
|
+
"(host and target test lists out of sync?)")
|
|
48
|
+
|
|
49
|
+
def run_all(self) -> "list[TestOutcome]":
|
|
50
|
+
return self._framework.parse_run(
|
|
51
|
+
self._run(self._exe, self._framework.run_all_argv()))
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: testaferro
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: pytest facade for DOS-based CppUTest unit testing, run via QEMU
|
|
5
|
+
Author-email: Paul Galbraith <paul@galbraiths.ca>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Keywords: pytest,cpputest,qemu,dos,testing,guest
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: Pytest
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: pytest
|
|
17
|
+
Requires-Dist: reliquary==0.1.0.dev2
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# testaferro
|
|
21
|
+
|
|
22
|
+
testaferro is a pytest facade for DOS-based CppUTest unit testing: a CppUTest suite built for DOS runs inside a QEMU
|
|
23
|
+
guest via reliquary, and its tests surface as pytest tests on the host — running, selecting, and
|
|
24
|
+
reporting them feels like an ordinary local pytest run.
|
|
25
|
+
|
|
26
|
+
DOS and CppUTest are what it supports today. Reliquary owns the guest-machine side; testaferro owns the pytest facade and
|
|
27
|
+
its test-framework adapters. Other platforms and frameworks are not built; what has been argued for them, and what the
|
|
28
|
+
project has decided so far, is in [planning/](planning/).
|
|
29
|
+
|
|
30
|
+
## Status: alpha
|
|
31
|
+
|
|
32
|
+
The architecture is built and works for its first target pair: a DOS-built CppUTest suite run inside a QEMU guest,
|
|
33
|
+
surfacing as ordinary pytest items on the host. It was verified end to end before the move onto reliquary's blueprint
|
|
34
|
+
model; since that move the unit suite passes but no guest has actually run, so treat a first run here as unproven
|
|
35
|
+
ground and expect to report what you find. testaferro is pre-1.0 and makes no compatibility promise: interfaces change
|
|
36
|
+
coherently and completely, without a bridge for the old shape.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
Hand the facade a reference to the suite executable in a normal pytest test module:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import testaferro
|
|
46
|
+
|
|
47
|
+
test_guest_case = testaferro.guest_suite(Path(__file__).parent / "TESTS.EXE")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
testaferro interrogates the referenced file and selects the matching guest backend: a DOS executable runs inside a
|
|
51
|
+
QEMU guest through reliquary (a dependency of testaferro), while a provably non-DOS binary — say, the host
|
|
52
|
+
build of the suite passed by mistake — is rejected with a clear error before any guest boots, naming the format and
|
|
53
|
+
architecture it found (Windows PE, Linux/BSD ELF, macOS Mach-O, 16-bit NE/LX/LE; x86 through ARM64). Headerless
|
|
54
|
+
images (`.com`-style raw code) carry nothing to prove, so they pass through for the guest itself to judge. The framework adapter
|
|
55
|
+
defaults to `testaferro.cpputest`; pass `framework=` to use a different one.
|
|
56
|
+
|
|
57
|
+
The guest machine's working state is testaferro's business, not the consumer's: each run happens in a fresh, disposable
|
|
58
|
+
reliquary home under testaferro's cache (`%LOCALAPPDATA%\testaferro` on Windows, `$XDG_CACHE_HOME/testaferro`
|
|
59
|
+
elsewhere), and the machine is created there fresh for the session and swept away with it. Zero configuration boots a
|
|
60
|
+
FreeDOS image that is downloaded once and cached; pass `boot_image=` to boot a caller-supplied DOS floppy image
|
|
61
|
+
instead.
|
|
62
|
+
|
|
63
|
+
The suite executable reaches the guest on a work drive testaferro adds to the machine: a host directory served into the
|
|
64
|
+
guest as a FAT volume, with the executable staged into it before boot. The guest sees it as its first hard disk —
|
|
65
|
+
normally `C:` — and testaferro runs it there by name. Nothing is written into your boot image.
|
|
66
|
+
|
|
67
|
+
### Named test machines
|
|
68
|
+
|
|
69
|
+
Declare a named machine once when several suites share it. A declaration is a reliquary **blueprint** — the machine's
|
|
70
|
+
own description, in reliquary's vocabulary. `config()` accepts blueprint machine fields directly (`memory`, `drives`,
|
|
71
|
+
`boot`, `backend_settings`, …), or a complete `machine_config=` template: a `MachineSpec`, a mapping, a whole blueprint
|
|
72
|
+
document, or a path to a `.rlqb` file. The template supplies its platform when it declares one; `platform=` is optional
|
|
73
|
+
and verifies an explicit choice.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import testaferro
|
|
77
|
+
|
|
78
|
+
testaferro.config("msdos", boot_image="images/msdos.img", memory=32)
|
|
79
|
+
|
|
80
|
+
test_guest_case = testaferro.guest_suite(
|
|
81
|
+
"build/TESTS.EXE", machine="msdos")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The same declarations can live in an optional per-project `testaferro.ini` — one section per machine, the
|
|
85
|
+
declarative twin of `config()`. `guest_suite()` searches upward from the calling test module and loads the file
|
|
86
|
+
automatically, so the suite can name only the executable when a unique machine matches:
|
|
87
|
+
|
|
88
|
+
```ini
|
|
89
|
+
[msdos]
|
|
90
|
+
boot_image = images/msdos.img
|
|
91
|
+
memory = 32
|
|
92
|
+
|
|
93
|
+
[custom]
|
|
94
|
+
machine_config = machines/custom.rlqb
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
test_guest_case = testaferro.guest_suite("build/TESTS.EXE")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Relative `boot_image` / `machine_config` / `template` paths resolve from the ini file's directory. Structured blueprint
|
|
102
|
+
fields (`drives`, `boot`, `scripts`, `backend_settings`, `control_planes`, `parameters`) accept JSON values; a bare
|
|
103
|
+
integer stays an integer, so `memory = 32` and `memory = 32M` are both accepted. Call `testaferro.load_config(path)` to
|
|
104
|
+
load an explicit file, or `load_config()` to search upward from the current directory.
|
|
105
|
+
|
|
106
|
+
A declaration is a template, never a running machine: every backend session creates a fresh machine from it, so runs do
|
|
107
|
+
not share guest state. What that costs per session is the blueprint's own business — reliquary's `materialize` mode on
|
|
108
|
+
each drive decides whether media is attached in place, copied, or layered. Use `platform="dos"` or `machine="msdos"`
|
|
109
|
+
when more than one configured DOS machine would otherwise match. With no declarations, DOS executables retain the
|
|
110
|
+
implicit downloaded-FreeDOS machine.
|
|
111
|
+
|
|
112
|
+
With several guest suites — or parallel pytest processes — open a *session*, so the image choice is made once and
|
|
113
|
+
every run's state is swept together. From the consuming project's `conftest.py`:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import testaferro
|
|
117
|
+
|
|
118
|
+
testaferro.start() # or testaferro.start(boot_image=...)
|
|
119
|
+
|
|
120
|
+
def pytest_unconfigure(config):
|
|
121
|
+
testaferro.stop()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`start()` costs nothing until a guest actually runs; `stop()` sweeps the session's staged image and every run home,
|
|
125
|
+
keeping the once-downloaded FreeDOS image cached for the next session (`stop(clear_downloads=True)` scrubs that too).
|
|
126
|
+
Forgetting `stop()` is not fatal — `start()` registers an `atexit` failsafe that sweeps the session at interpreter
|
|
127
|
+
exit — but the explicit call is still preferred: it cleans up at a deterministic point and is where
|
|
128
|
+
`clear_downloads=True` can be said.
|
|
129
|
+
|
|
130
|
+
Because every run gets a private home and a private image copy, suites in separate pytest processes never share
|
|
131
|
+
mutable guest state — safe to parallelize. With [pytest-xdist](https://pypi.org/project/pytest-xdist/), run
|
|
132
|
+
`pytest -n auto --dist loadfile`: `loadfile` keeps each test file's items on one worker, so a whole guest suite stays
|
|
133
|
+
together (preserving the one-boot `run_all()` batching) while *different* suites boot their guests concurrently on
|
|
134
|
+
other workers. Plain `--dist load` would scatter a suite's items across workers and degrade it to one boot per test.
|
|
135
|
+
|
|
136
|
+
Backend lifecycle hooks are automatic. Enumeration runs in a short collection session; selected tests then share one
|
|
137
|
+
execution session, which is cleaned up by pytest even when a test fails.
|
|
138
|
+
|
|
139
|
+
Every test in the guest suite becomes its own pytest item, so pytest's selection drives what actually runs remotely:
|
|
140
|
+
|
|
141
|
+
- run everything (`pytest`) and the facade batches the whole suite into a single guest run — one execution boot for the
|
|
142
|
+
session;
|
|
143
|
+
- narrow the selection (`pytest -k Wraps`, an explicit node id) and only the selected tests run in the guest,
|
|
144
|
+
individually.
|
|
145
|
+
|
|
146
|
+
A failing guest test fails its pytest item with the guest side's original file, line, and assertion message — not a
|
|
147
|
+
traceback into the facade. IDE test integrations work per item too: the generated test function reports the
|
|
148
|
+
`guest_suite()` call site as its source, so run-this-test and jump-to-source in PyCharm-style test trees resolve to
|
|
149
|
+
your module.
|
|
150
|
+
|
|
151
|
+
Under the hood, reliquary owns the guest machine and a framework adapter knows the suite's argv and output grammar.
|
|
152
|
+
`testaferro.suite.SuiteBackend` composes reliquary execution with the selected adapter; `guest_suite()` also accepts a
|
|
153
|
+
prebuilt backend as the custom escape hatch.
|
|
154
|
+
|
|
155
|
+
Enumeration can be delegated to a host-built twin of a guest suite through `enumerator=`, and for a suite of any size
|
|
156
|
+
it should be. Guest output is captured by reading the guest's screen, so a command whose output scrolls past one
|
|
157
|
+
screenful leaves only its tail there — which bites enumeration hardest, since `-ln` lists every test at once. A host
|
|
158
|
+
twin built from the same sources both avoids that and keeps the two builds honest against each other: a test the guest
|
|
159
|
+
build dropped shows up as one that did not run.
|
|
160
|
+
|
|
161
|
+
The framework adapter remains usable directly where the facade is more than you need — it is just argv and grammar,
|
|
162
|
+
independent of how you obtained the output:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from testaferro import cpputest
|
|
166
|
+
|
|
167
|
+
results = cpputest.parse(log) # {"ran", "failed", "summary"}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Tests
|
|
171
|
+
|
|
172
|
+
```powershell
|
|
173
|
+
python -m unittest discover -s tests -v
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Contributing
|
|
177
|
+
|
|
178
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the required checks, and contribution licensing terms.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
BSD 3-Clause; see [LICENSE](LICENSE). The project follows REUSE conventions (SPDX headers
|
|
183
|
+
plus [REUSE.toml](REUSE.toml)).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
testaferro/__init__.py,sha256=SndrEaMGYlN6l-yUC7e59gEufRuHjckHhN-zUzKmzZs,3038
|
|
2
|
+
testaferro/backend.py,sha256=k9Zg4SL7SZWDhXWOOWEeG_QFAEa2x2C_9GJlmfe6Ccc,1861
|
|
3
|
+
testaferro/binfmt.py,sha256=3npAcWwRxpn7OMF8M7okQIiWi1NhwS5mXwIpMdehgDs,4658
|
|
4
|
+
testaferro/cache.py,sha256=h9a7uxbCOsW_1sFJ-98o5o8kHjAqXURjLza2xONHx58,789
|
|
5
|
+
testaferro/cpputest.py,sha256=AUWTYBY7IAX14ckeORcEKa863AAofWrdbYdvy0i61nM,4639
|
|
6
|
+
testaferro/facade.py,sha256=pL8NX762S2NMvIVrqyCuMczJq2SfGzq7JdIAfJ7fh8Q,9440
|
|
7
|
+
testaferro/machines.py,sha256=7N6M_hpXCXKWlRqhrM018-KKJIX9ePXjc9I5dKwVgu8,15196
|
|
8
|
+
testaferro/qemu.py,sha256=g0etBiGCQQrziwiWaiWGlpajmsCiVCmzbmE3is1G-qM,14895
|
|
9
|
+
testaferro/suite.py,sha256=9KwDr71DgLuFBoExlDx8kB29iVOofF5phLuWPmLc24Y,1957
|
|
10
|
+
testaferro-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=PQZNmWpJyQDBeiUj6i7KYIamCtH1SzVXSW4Q4sTfA9c,1501
|
|
11
|
+
testaferro-0.1.0.dev0.dist-info/METADATA,sha256=zdkV3_3ZH4L7Nsd3HSBZRwGp_ARFVTgP3DVPXm8-Q7s,9602
|
|
12
|
+
testaferro-0.1.0.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
testaferro-0.1.0.dev0.dist-info/top_level.txt,sha256=tvsT9wvRXkh2cWG6ruTDM4xd0dsR5yzM2E0gHWF4pyc,11
|
|
14
|
+
testaferro-0.1.0.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Paul Galbraith
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
this list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
testaferro
|