testaferro 0.1.0.dev0__tar.gz
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-0.1.0.dev0/LICENSE +29 -0
- testaferro-0.1.0.dev0/PKG-INFO +183 -0
- testaferro-0.1.0.dev0/README.md +164 -0
- testaferro-0.1.0.dev0/pyproject.toml +30 -0
- testaferro-0.1.0.dev0/setup.cfg +4 -0
- testaferro-0.1.0.dev0/testaferro/__init__.py +83 -0
- testaferro-0.1.0.dev0/testaferro/backend.py +65 -0
- testaferro-0.1.0.dev0/testaferro/binfmt.py +109 -0
- testaferro-0.1.0.dev0/testaferro/cache.py +21 -0
- testaferro-0.1.0.dev0/testaferro/cpputest.py +128 -0
- testaferro-0.1.0.dev0/testaferro/facade.py +227 -0
- testaferro-0.1.0.dev0/testaferro/machines.py +400 -0
- testaferro-0.1.0.dev0/testaferro/qemu.py +364 -0
- testaferro-0.1.0.dev0/testaferro/suite.py +51 -0
- testaferro-0.1.0.dev0/testaferro.egg-info/PKG-INFO +183 -0
- testaferro-0.1.0.dev0/testaferro.egg-info/SOURCES.txt +24 -0
- testaferro-0.1.0.dev0/testaferro.egg-info/dependency_links.txt +1 -0
- testaferro-0.1.0.dev0/testaferro.egg-info/requires.txt +2 -0
- testaferro-0.1.0.dev0/testaferro.egg-info/top_level.txt +1 -0
- testaferro-0.1.0.dev0/tests/test_binfmt.py +127 -0
- testaferro-0.1.0.dev0/tests/test_cpputest.py +103 -0
- testaferro-0.1.0.dev0/tests/test_facade.py +328 -0
- testaferro-0.1.0.dev0/tests/test_machines.py +86 -0
- testaferro-0.1.0.dev0/tests/test_project_config.py +148 -0
- testaferro-0.1.0.dev0/tests/test_qemu.py +486 -0
- testaferro-0.1.0.dev0/tests/test_suite.py +87 -0
|
@@ -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,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,164 @@
|
|
|
1
|
+
# testaferro
|
|
2
|
+
|
|
3
|
+
testaferro is a pytest facade for DOS-based CppUTest unit testing: a CppUTest suite built for DOS runs inside a QEMU
|
|
4
|
+
guest via reliquary, and its tests surface as pytest tests on the host — running, selecting, and
|
|
5
|
+
reporting them feels like an ordinary local pytest run.
|
|
6
|
+
|
|
7
|
+
DOS and CppUTest are what it supports today. Reliquary owns the guest-machine side; testaferro owns the pytest facade and
|
|
8
|
+
its test-framework adapters. Other platforms and frameworks are not built; what has been argued for them, and what the
|
|
9
|
+
project has decided so far, is in [planning/](planning/).
|
|
10
|
+
|
|
11
|
+
## Status: alpha
|
|
12
|
+
|
|
13
|
+
The architecture is built and works for its first target pair: a DOS-built CppUTest suite run inside a QEMU guest,
|
|
14
|
+
surfacing as ordinary pytest items on the host. It was verified end to end before the move onto reliquary's blueprint
|
|
15
|
+
model; since that move the unit suite passes but no guest has actually run, so treat a first run here as unproven
|
|
16
|
+
ground and expect to report what you find. testaferro is pre-1.0 and makes no compatibility promise: interfaces change
|
|
17
|
+
coherently and completely, without a bridge for the old shape.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Hand the facade a reference to the suite executable in a normal pytest test module:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import testaferro
|
|
27
|
+
|
|
28
|
+
test_guest_case = testaferro.guest_suite(Path(__file__).parent / "TESTS.EXE")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
testaferro interrogates the referenced file and selects the matching guest backend: a DOS executable runs inside a
|
|
32
|
+
QEMU guest through reliquary (a dependency of testaferro), while a provably non-DOS binary — say, the host
|
|
33
|
+
build of the suite passed by mistake — is rejected with a clear error before any guest boots, naming the format and
|
|
34
|
+
architecture it found (Windows PE, Linux/BSD ELF, macOS Mach-O, 16-bit NE/LX/LE; x86 through ARM64). Headerless
|
|
35
|
+
images (`.com`-style raw code) carry nothing to prove, so they pass through for the guest itself to judge. The framework adapter
|
|
36
|
+
defaults to `testaferro.cpputest`; pass `framework=` to use a different one.
|
|
37
|
+
|
|
38
|
+
The guest machine's working state is testaferro's business, not the consumer's: each run happens in a fresh, disposable
|
|
39
|
+
reliquary home under testaferro's cache (`%LOCALAPPDATA%\testaferro` on Windows, `$XDG_CACHE_HOME/testaferro`
|
|
40
|
+
elsewhere), and the machine is created there fresh for the session and swept away with it. Zero configuration boots a
|
|
41
|
+
FreeDOS image that is downloaded once and cached; pass `boot_image=` to boot a caller-supplied DOS floppy image
|
|
42
|
+
instead.
|
|
43
|
+
|
|
44
|
+
The suite executable reaches the guest on a work drive testaferro adds to the machine: a host directory served into the
|
|
45
|
+
guest as a FAT volume, with the executable staged into it before boot. The guest sees it as its first hard disk —
|
|
46
|
+
normally `C:` — and testaferro runs it there by name. Nothing is written into your boot image.
|
|
47
|
+
|
|
48
|
+
### Named test machines
|
|
49
|
+
|
|
50
|
+
Declare a named machine once when several suites share it. A declaration is a reliquary **blueprint** — the machine's
|
|
51
|
+
own description, in reliquary's vocabulary. `config()` accepts blueprint machine fields directly (`memory`, `drives`,
|
|
52
|
+
`boot`, `backend_settings`, …), or a complete `machine_config=` template: a `MachineSpec`, a mapping, a whole blueprint
|
|
53
|
+
document, or a path to a `.rlqb` file. The template supplies its platform when it declares one; `platform=` is optional
|
|
54
|
+
and verifies an explicit choice.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import testaferro
|
|
58
|
+
|
|
59
|
+
testaferro.config("msdos", boot_image="images/msdos.img", memory=32)
|
|
60
|
+
|
|
61
|
+
test_guest_case = testaferro.guest_suite(
|
|
62
|
+
"build/TESTS.EXE", machine="msdos")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The same declarations can live in an optional per-project `testaferro.ini` — one section per machine, the
|
|
66
|
+
declarative twin of `config()`. `guest_suite()` searches upward from the calling test module and loads the file
|
|
67
|
+
automatically, so the suite can name only the executable when a unique machine matches:
|
|
68
|
+
|
|
69
|
+
```ini
|
|
70
|
+
[msdos]
|
|
71
|
+
boot_image = images/msdos.img
|
|
72
|
+
memory = 32
|
|
73
|
+
|
|
74
|
+
[custom]
|
|
75
|
+
machine_config = machines/custom.rlqb
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
test_guest_case = testaferro.guest_suite("build/TESTS.EXE")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Relative `boot_image` / `machine_config` / `template` paths resolve from the ini file's directory. Structured blueprint
|
|
83
|
+
fields (`drives`, `boot`, `scripts`, `backend_settings`, `control_planes`, `parameters`) accept JSON values; a bare
|
|
84
|
+
integer stays an integer, so `memory = 32` and `memory = 32M` are both accepted. Call `testaferro.load_config(path)` to
|
|
85
|
+
load an explicit file, or `load_config()` to search upward from the current directory.
|
|
86
|
+
|
|
87
|
+
A declaration is a template, never a running machine: every backend session creates a fresh machine from it, so runs do
|
|
88
|
+
not share guest state. What that costs per session is the blueprint's own business — reliquary's `materialize` mode on
|
|
89
|
+
each drive decides whether media is attached in place, copied, or layered. Use `platform="dos"` or `machine="msdos"`
|
|
90
|
+
when more than one configured DOS machine would otherwise match. With no declarations, DOS executables retain the
|
|
91
|
+
implicit downloaded-FreeDOS machine.
|
|
92
|
+
|
|
93
|
+
With several guest suites — or parallel pytest processes — open a *session*, so the image choice is made once and
|
|
94
|
+
every run's state is swept together. From the consuming project's `conftest.py`:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import testaferro
|
|
98
|
+
|
|
99
|
+
testaferro.start() # or testaferro.start(boot_image=...)
|
|
100
|
+
|
|
101
|
+
def pytest_unconfigure(config):
|
|
102
|
+
testaferro.stop()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`start()` costs nothing until a guest actually runs; `stop()` sweeps the session's staged image and every run home,
|
|
106
|
+
keeping the once-downloaded FreeDOS image cached for the next session (`stop(clear_downloads=True)` scrubs that too).
|
|
107
|
+
Forgetting `stop()` is not fatal — `start()` registers an `atexit` failsafe that sweeps the session at interpreter
|
|
108
|
+
exit — but the explicit call is still preferred: it cleans up at a deterministic point and is where
|
|
109
|
+
`clear_downloads=True` can be said.
|
|
110
|
+
|
|
111
|
+
Because every run gets a private home and a private image copy, suites in separate pytest processes never share
|
|
112
|
+
mutable guest state — safe to parallelize. With [pytest-xdist](https://pypi.org/project/pytest-xdist/), run
|
|
113
|
+
`pytest -n auto --dist loadfile`: `loadfile` keeps each test file's items on one worker, so a whole guest suite stays
|
|
114
|
+
together (preserving the one-boot `run_all()` batching) while *different* suites boot their guests concurrently on
|
|
115
|
+
other workers. Plain `--dist load` would scatter a suite's items across workers and degrade it to one boot per test.
|
|
116
|
+
|
|
117
|
+
Backend lifecycle hooks are automatic. Enumeration runs in a short collection session; selected tests then share one
|
|
118
|
+
execution session, which is cleaned up by pytest even when a test fails.
|
|
119
|
+
|
|
120
|
+
Every test in the guest suite becomes its own pytest item, so pytest's selection drives what actually runs remotely:
|
|
121
|
+
|
|
122
|
+
- run everything (`pytest`) and the facade batches the whole suite into a single guest run — one execution boot for the
|
|
123
|
+
session;
|
|
124
|
+
- narrow the selection (`pytest -k Wraps`, an explicit node id) and only the selected tests run in the guest,
|
|
125
|
+
individually.
|
|
126
|
+
|
|
127
|
+
A failing guest test fails its pytest item with the guest side's original file, line, and assertion message — not a
|
|
128
|
+
traceback into the facade. IDE test integrations work per item too: the generated test function reports the
|
|
129
|
+
`guest_suite()` call site as its source, so run-this-test and jump-to-source in PyCharm-style test trees resolve to
|
|
130
|
+
your module.
|
|
131
|
+
|
|
132
|
+
Under the hood, reliquary owns the guest machine and a framework adapter knows the suite's argv and output grammar.
|
|
133
|
+
`testaferro.suite.SuiteBackend` composes reliquary execution with the selected adapter; `guest_suite()` also accepts a
|
|
134
|
+
prebuilt backend as the custom escape hatch.
|
|
135
|
+
|
|
136
|
+
Enumeration can be delegated to a host-built twin of a guest suite through `enumerator=`, and for a suite of any size
|
|
137
|
+
it should be. Guest output is captured by reading the guest's screen, so a command whose output scrolls past one
|
|
138
|
+
screenful leaves only its tail there — which bites enumeration hardest, since `-ln` lists every test at once. A host
|
|
139
|
+
twin built from the same sources both avoids that and keeps the two builds honest against each other: a test the guest
|
|
140
|
+
build dropped shows up as one that did not run.
|
|
141
|
+
|
|
142
|
+
The framework adapter remains usable directly where the facade is more than you need — it is just argv and grammar,
|
|
143
|
+
independent of how you obtained the output:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from testaferro import cpputest
|
|
147
|
+
|
|
148
|
+
results = cpputest.parse(log) # {"ran", "failed", "summary"}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Tests
|
|
152
|
+
|
|
153
|
+
```powershell
|
|
154
|
+
python -m unittest discover -s tests -v
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Contributing
|
|
158
|
+
|
|
159
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the required checks, and contribution licensing terms.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
BSD 3-Clause; see [LICENSE](LICENSE). The project follows REUSE conventions (SPDX headers
|
|
164
|
+
plus [REUSE.toml](REUSE.toml)).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paul Galbraith
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
[build-system]
|
|
5
|
+
requires = ["setuptools>=77"]
|
|
6
|
+
build-backend = "setuptools.build_meta"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "testaferro"
|
|
10
|
+
version = "0.1.0.dev0"
|
|
11
|
+
description = "pytest facade for DOS-based CppUTest unit testing, run via QEMU"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
authors = [{ name = "Paul Galbraith", email = "paul@galbraiths.ca" }]
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
license = "BSD-3-Clause"
|
|
16
|
+
license-files = ["LICENSE"]
|
|
17
|
+
keywords = ["pytest", "cpputest", "qemu", "dos", "testing", "guest"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Framework :: Pytest",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Software Development :: Testing",
|
|
24
|
+
]
|
|
25
|
+
# pytest is the facade's host surface (testaferro.facade). QemuSuiteBackend
|
|
26
|
+
# requires reliquary, which is included as a dependency.
|
|
27
|
+
dependencies = ["pytest", "reliquary==0.1.0.dev2"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["testaferro"]
|
|
@@ -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)
|
|
@@ -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()."""
|
|
@@ -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
|