messagefoundry-harness 0.2.4__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.
- harness/README.md +101 -0
- harness/__main__.py +293 -0
- harness/acceptance/__init__.py +21 -0
- harness/acceptance/__main__.py +93 -0
- harness/acceptance/matrix.py +528 -0
- harness/acceptance/probes.py +220 -0
- harness/acceptance/report.py +147 -0
- harness/acceptance/runner.py +176 -0
- harness/compose.py +225 -0
- harness/config/coverage.py +80 -0
- harness/config/load/_shape.py +134 -0
- harness/config/load/graph.py +90 -0
- harness/file_panel.py +250 -0
- harness/file_transport.py +165 -0
- harness/load/__init__.py +17 -0
- harness/load/corpus.py +176 -0
- harness/load/correlator.py +73 -0
- harness/load/enginepoll.py +162 -0
- harness/load/failover.py +815 -0
- harness/load/failover_track.py +143 -0
- harness/load/governor.py +109 -0
- harness/load/ids.py +42 -0
- harness/load/metrics.py +219 -0
- harness/load/profile.py +629 -0
- harness/load/profiles/README.md +33 -0
- harness/load/profiles/closed-loop.toml +100 -0
- harness/load/profiles/failover.toml +55 -0
- harness/load/profiles/fanout-baseline.toml +92 -0
- harness/load/profiles/malformed-load.toml +44 -0
- harness/load/profiles/reference.toml +100 -0
- harness/load/profiles/smoke-sqlserver.toml +42 -0
- harness/load/profiles/smoke.toml +36 -0
- harness/load/profiles/soak.toml +45 -0
- harness/load/profiles/spike-burst.toml +50 -0
- harness/load/profiles/sustained-overload.toml +51 -0
- harness/load/profiles/writeamp.toml +41 -0
- harness/load/report.py +582 -0
- harness/load/runner.py +166 -0
- harness/load/sender.py +322 -0
- harness/load/sink.py +136 -0
- harness/mllp.py +271 -0
- harness/monitor.py +547 -0
- harness/receive.py +137 -0
- harness/reconcile/__init__.py +49 -0
- harness/reconcile/__main__.py +149 -0
- harness/reconcile/capture.py +150 -0
- harness/reconcile/compare.py +179 -0
- harness/reconcile/normalize.py +234 -0
- harness/reconcile/report.py +73 -0
- harness/scenarios.py +195 -0
- harness/send.py +175 -0
- harness/window.py +52 -0
- messagefoundry_harness-0.2.4.dist-info/METADATA +56 -0
- messagefoundry_harness-0.2.4.dist-info/RECORD +56 -0
- messagefoundry_harness-0.2.4.dist-info/WHEEL +4 -0
- messagefoundry_harness-0.2.4.dist-info/entry_points.txt +2 -0
harness/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Test harness
|
|
2
|
+
|
|
3
|
+
A standalone PySide6 tool to exercise **everything the engine can do** with synthetic, PHI-free
|
|
4
|
+
traffic — send and receive HL7 v2 over MLLP and file, send malformed messages, inject delivery
|
|
5
|
+
faults, and watch what a running engine actually did with each message.
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
python -m harness # launch the GUI
|
|
9
|
+
python -m harness --list-scenarios # list headless scenarios
|
|
10
|
+
python -m harness --scenario processed # run one scenario in CI (exit 0 pass / 1 fail)
|
|
11
|
+
python -m harness --list-profiles # list headless load profiles
|
|
12
|
+
python -m harness --load smoke # run a load profile (exit 0 SLOs met / 1 violation / 2 setup)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It reuses the engine's own MLLP framing + ACK builder (`messagefoundry.transports.mllp`), message
|
|
16
|
+
generators (`messagefoundry/generators`), and API client (`messagefoundry.console.client`), so it
|
|
17
|
+
frames, acknowledges, and reads engine state exactly as the real components do. New message types
|
|
18
|
+
light up automatically as they're added to `messagefoundry/generators/all_types.py`.
|
|
19
|
+
|
|
20
|
+
## GUI tabs
|
|
21
|
+
|
|
22
|
+
- **Send** — pick a message type/trigger (or "random across all"), a count, target `host:port`,
|
|
23
|
+
and an optional rate; fire them and watch per-message ACK code / latency / errors. Sending runs
|
|
24
|
+
on a worker thread so the UI stays responsive.
|
|
25
|
+
- **Receive** — start a localhost MLLP listener and reply with a configurable mode. Beyond
|
|
26
|
+
**AA / AE / AR / none**, the reply can inject faults to drive the engine's *outbound* retry /
|
|
27
|
+
dead-letter / independent-draining behavior: **delay then AA** (set the delay past the engine's
|
|
28
|
+
timeout to force a retry), **close (no reply)**, and **fail N then AA** (reject the first N
|
|
29
|
+
deliveries of a control id, then accept). Repeated control ids — the engine's at-least-once
|
|
30
|
+
retries — are counted and highlighted.
|
|
31
|
+
- **File** — *Drop* generated messages into the engine's File-inbound directory (atomic writes,
|
|
32
|
+
so the engine never polls a half-written file), and *Watch* its File-outbound directory, parsing
|
|
33
|
+
and displaying each file that appears. Defaults match `harness/config`.
|
|
34
|
+
- **Compose** — send an arbitrary, hand-edited message (preset seeds: valid, no-MSH,
|
|
35
|
+
wrong-version) over MLLP with an explicit **ACK expectation** (Accept / Reject / No ACK), flagged
|
|
36
|
+
against the actual reply — or drop it as a file. This reaches the ERROR / AR / AE / strict-
|
|
37
|
+
validation paths the generators can't.
|
|
38
|
+
- **Monitor** — connect to a running engine's API (reusing the console's sign-in) and observe what
|
|
39
|
+
it did: live outbox stats + a connections table (polled off the UI thread), the message store
|
|
40
|
+
with per-message disposition and full delivery/audit trail, the dead-letter queue with scoped +
|
|
41
|
+
bulk replay, and a config-reload button. Connection start/stop/restart/purge controls included.
|
|
42
|
+
|
|
43
|
+
## Driving a complete engine
|
|
44
|
+
|
|
45
|
+
`harness/config` is a self-contained config graph wired to produce **every** disposition
|
|
46
|
+
and delivery path (see its docstring). Serve it, then drive it from the tabs above:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
python -m messagefoundry serve --config harness/config --db ./messagefoundry.db --env dev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Send (Send/Compose tab → 127.0.0.1:2575) | Disposition (Monitor tab) |
|
|
53
|
+
|------------------------------------------|---------------------------|
|
|
54
|
+
| ADT^A01 / A04 / A08 | PROCESSED (fan-out: MLLP echo + file) |
|
|
55
|
+
| any other ADT trigger | PROCESSED (file archive) |
|
|
56
|
+
| ADT^A02 | FILTERED |
|
|
57
|
+
| ADT^A03 | ERROR (AE NAK) |
|
|
58
|
+
| any non-ADT type | UNROUTED |
|
|
59
|
+
| malformed / wrong version (→ 2577) | ERROR (AE NAK) |
|
|
60
|
+
|
|
61
|
+
To see retries → dead-letter → replay: leave the Receive tab **not** listening on 2576 (or set it
|
|
62
|
+
to *fail N then AA* / *close*), send an ADT^A01, and watch the echo delivery dead-letter in the
|
|
63
|
+
Monitor's Dead Letters tab — the file archive for the same message still succeeds (independent
|
|
64
|
+
draining). Then replay it from there.
|
|
65
|
+
|
|
66
|
+
## Headless scenarios (CI)
|
|
67
|
+
|
|
68
|
+
The scenario runner generates traffic, sends it, and asserts the engine's resulting disposition
|
|
69
|
+
(or dead-lettering) over the API — Qt-free, so it runs on a display-less runner. Built-in
|
|
70
|
+
scenarios target `harness/config`; serve it, then:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
python -m harness --scenario processed # ADT^A05 → file → PROCESSED
|
|
74
|
+
python -m harness --scenario filtered # ADT^A02 → FILTERED
|
|
75
|
+
python -m harness --scenario unrouted # ORU → UNROUTED
|
|
76
|
+
python -m harness --scenario error # ADT^A03 → ERROR
|
|
77
|
+
python -m harness --scenario dead_letter # ADT^A01 echo with nothing on 2576
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Pass `--engine <url>` for a non-default API address and `--token <t>` for an auth-enabled engine.
|
|
81
|
+
|
|
82
|
+
## Load testing (headless)
|
|
83
|
+
|
|
84
|
+
A separate, **Qt-free** asyncio load engine (`harness/load/`) drives the engine under heavy MLLP
|
|
85
|
+
traffic and measures it — the GUI's single-thread sender can't saturate it. A pool of **persistent,
|
|
86
|
+
pipelined** connections offers a data-driven [load profile](load/profiles/) (warmup → ramp →
|
|
87
|
+
sustained → spike → soak); a fast **correlation sink** absorbs the engine's outbound fan-out and
|
|
88
|
+
times every message end-to-end; an engine poller samples the API for throughput, backlog, DB growth,
|
|
89
|
+
and post-load drain. The run ends in an SLO verdict + a no-loss reconciliation and a JSON/CSV report.
|
|
90
|
+
|
|
91
|
+
Serve the synthetic high-fan-out [system-under-test](config/load/) (separate from `harness/config`),
|
|
92
|
+
then run a profile:
|
|
93
|
+
|
|
94
|
+
```powershell
|
|
95
|
+
$env:MEFOR_LOAD_FANOUT=20; $env:MEFOR_LOAD_TRANSFORM="edit"; $env:MEFOR_LOAD_SINK_PORT=2700
|
|
96
|
+
python -m messagefoundry serve --config harness/config/load --db ./load.db # swap --db for backends
|
|
97
|
+
python -m harness --load fanout-baseline --engine URL --token T --report-json out/load/run.json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Full guide — profile schema, the env knobs, reading the report/SLOs, exit codes, baseline
|
|
101
|
+
comparison, and the backend-comparison recipe — is in [docs/LOAD-TESTING.md](../docs/LOAD-TESTING.md).
|
harness/__main__.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Test harness entrypoint.
|
|
4
|
+
|
|
5
|
+
``python -m harness`` → launch the GUI (Send/Receive/File/Compose/Monitor).
|
|
6
|
+
``python -m harness --list-scenarios``→ list the built-in scenarios.
|
|
7
|
+
``python -m harness --scenario NAME`` → run one scenario headless against a running engine
|
|
8
|
+
and exit 0 (pass) / 1 (fail) — for CI.
|
|
9
|
+
``python -m harness --list-profiles`` → list the built-in load profiles.
|
|
10
|
+
``python -m harness --load NAME`` → run a load profile headless against a running engine and exit
|
|
11
|
+
0 (SLOs met) / 1 (SLO violation, incl. zero_loss when
|
|
12
|
+
the profile sets it, or a baseline regression) / 2
|
|
13
|
+
(setup error) / 3 (interrupted).
|
|
14
|
+
``python -m harness --failover NAME`` → run the two-node primary-kill scenario (the profile must carry a
|
|
15
|
+
[load.failover] table) against a shared server DB
|
|
16
|
+
(MEFOR_STORE_BACKEND=postgres|sqlserver + MEFOR_STORE_*);
|
|
17
|
+
the harness OWNS the two engines, SIGKILLs the primary
|
|
18
|
+
mid-load, and exits 0 (recovery + no-loss + ordering met)
|
|
19
|
+
/ 1 (an SLO violated) / 2 (setup) / 3 (interrupted).
|
|
20
|
+
|
|
21
|
+
The headless paths import no PySide6, so they run on a display-less runner; the GUI import is
|
|
22
|
+
deferred into :func:`_launch_gui`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
# Scenario text uses arrows (U+2192); a legacy Windows console (cp1252) would otherwise raise
|
|
33
|
+
# UnicodeEncodeError when --list-scenarios / --scenario prints them, breaking the documented
|
|
34
|
+
# CI use. Force UTF-8 on the CLI streams — best-effort, since a pytest/redirect wrapper may
|
|
35
|
+
# not support reconfigure.
|
|
36
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
37
|
+
try:
|
|
38
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
39
|
+
except (AttributeError, ValueError, OSError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
parser = argparse.ArgumentParser(prog="harness", description="MessageFoundry test harness")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--scenario", help="run this scenario headless and exit (see --list-scenarios)"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--list-scenarios", action="store_true", help="list built-in scenarios")
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--load", help="run this load profile (built-in name or path to a .toml) and exit"
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--failover",
|
|
52
|
+
help="run this profile's two-node primary-kill scenario (needs a [load.failover] table + a "
|
|
53
|
+
"shared server DB via MEFOR_STORE_*) and exit",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--inbound-base-port",
|
|
57
|
+
type=int,
|
|
58
|
+
default=2600,
|
|
59
|
+
help="failover: base inbound MLLP port (ADT hub; results/other = base+1/+2). Both nodes bind it",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument("--list-profiles", action="store_true", help="list built-in load profiles")
|
|
62
|
+
parser.add_argument("--engine", default="http://127.0.0.1:8765", help="engine API base URL")
|
|
63
|
+
parser.add_argument("--token", help="bearer token for an auth-enabled engine")
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--timeout", type=float, default=30.0, help="scenario: seconds to wait for the outcome"
|
|
66
|
+
)
|
|
67
|
+
# Load-run options.
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--sink-port", type=int, default=2700, help="load: base correlation-sink port"
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument("--sink-ports", type=int, default=1, help="load: contiguous sink ports")
|
|
72
|
+
parser.add_argument("--report-json", help="load: write the JSON report to this path")
|
|
73
|
+
parser.add_argument("--report-csv", help="load: write the per-phase CSV to this path")
|
|
74
|
+
parser.add_argument("--baseline", help="load: compare against this saved JSON report")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--tolerance",
|
|
77
|
+
type=float,
|
|
78
|
+
default=0.1,
|
|
79
|
+
help="load: baseline regression tolerance (fraction)",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument("--db-backend", help="load: label the store backend in the report")
|
|
82
|
+
args = parser.parse_args(argv)
|
|
83
|
+
|
|
84
|
+
if args.list_scenarios:
|
|
85
|
+
return _list_scenarios()
|
|
86
|
+
if args.list_profiles:
|
|
87
|
+
return _list_profiles()
|
|
88
|
+
if sum(bool(x) for x in (args.load, args.scenario, args.failover)) > 1:
|
|
89
|
+
print("--load, --failover, and --scenario are mutually exclusive", file=sys.stderr)
|
|
90
|
+
return 2
|
|
91
|
+
if args.failover:
|
|
92
|
+
return _run_failover(args)
|
|
93
|
+
if args.load:
|
|
94
|
+
return _run_load(args)
|
|
95
|
+
if args.scenario:
|
|
96
|
+
return _run_scenario(args.scenario, args.engine, args.token, args.timeout)
|
|
97
|
+
return _launch_gui()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _list_scenarios() -> int:
|
|
101
|
+
from harness.scenarios import SCENARIOS
|
|
102
|
+
|
|
103
|
+
for name, scenario in SCENARIOS.items():
|
|
104
|
+
print(f" {name:<12} {scenario.description}")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_scenario(name: str, engine_url: str, token: str | None, timeout: float) -> int:
|
|
109
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
110
|
+
from harness.scenarios import SCENARIOS, run_scenario
|
|
111
|
+
|
|
112
|
+
scenario = SCENARIOS.get(name)
|
|
113
|
+
if scenario is None:
|
|
114
|
+
print(f"unknown scenario {name!r}; choices: {', '.join(SCENARIOS)}", file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
try:
|
|
117
|
+
with EngineClient(engine_url) as client:
|
|
118
|
+
if token:
|
|
119
|
+
client.set_token(token)
|
|
120
|
+
result = run_scenario(scenario, client, timeout=timeout)
|
|
121
|
+
except ApiError as exc:
|
|
122
|
+
print(f"FAIL {name}: {exc}", file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
print(f"{'PASS' if result.ok else 'FAIL'} {name}: {result.detail}")
|
|
125
|
+
return 0 if result.ok else 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _list_profiles() -> int:
|
|
129
|
+
from harness.load.profile import list_profiles
|
|
130
|
+
|
|
131
|
+
for name, description in list_profiles().items():
|
|
132
|
+
print(f" {name:<18} {description}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _run_load(args: argparse.Namespace) -> int:
|
|
137
|
+
import asyncio
|
|
138
|
+
import json
|
|
139
|
+
import os
|
|
140
|
+
import time
|
|
141
|
+
from pathlib import Path
|
|
142
|
+
|
|
143
|
+
from messagefoundry.console.client import ApiError
|
|
144
|
+
|
|
145
|
+
from harness.load.profile import LoadProfileError, get_profile
|
|
146
|
+
from harness.load.report import compare_to_baseline
|
|
147
|
+
from harness.load.runner import PreflightError, run_load
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
profile = get_profile(args.load)
|
|
151
|
+
except LoadProfileError as exc:
|
|
152
|
+
print(f"bad profile: {exc}", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
# A run-scoped, ASCII-alnum control-id prefix so a re-run can't collide with a prior run's ids in
|
|
156
|
+
# a long-lived DB. (pid + monotonic ns; no wall clock needed.)
|
|
157
|
+
prefix = f"L{os.getpid():x}{time.perf_counter_ns():x}"[:16]
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
report = asyncio.run(
|
|
161
|
+
run_load(
|
|
162
|
+
profile,
|
|
163
|
+
engine_url=args.engine,
|
|
164
|
+
id_prefix=prefix,
|
|
165
|
+
token=args.token,
|
|
166
|
+
sink_port=args.sink_port,
|
|
167
|
+
sink_ports=args.sink_ports,
|
|
168
|
+
db_backend=args.db_backend,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
except PreflightError as exc:
|
|
172
|
+
print(f"preflight failed: {exc}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
except ApiError as exc:
|
|
175
|
+
# A bad/expired --token or an engine that's down surfaces here (the client validates the token
|
|
176
|
+
# via /auth/me before preflight). That's a setup failure, not an SLO violation → exit 2.
|
|
177
|
+
print(f"engine setup failed: {exc}", file=sys.stderr)
|
|
178
|
+
return 2
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
print("interrupted", file=sys.stderr)
|
|
181
|
+
return 3
|
|
182
|
+
|
|
183
|
+
print(report.render_console())
|
|
184
|
+
|
|
185
|
+
if args.report_json:
|
|
186
|
+
Path(args.report_json).write_text(report.to_json(), encoding="utf-8")
|
|
187
|
+
if args.report_csv:
|
|
188
|
+
Path(args.report_csv).write_text(report.to_csv(), encoding="utf-8")
|
|
189
|
+
|
|
190
|
+
exit_code = report.exit_code
|
|
191
|
+
if args.baseline:
|
|
192
|
+
try:
|
|
193
|
+
baseline = json.loads(Path(args.baseline).read_text(encoding="utf-8"))
|
|
194
|
+
except (OSError, ValueError) as exc:
|
|
195
|
+
print(f"could not read baseline {args.baseline!r}: {exc}", file=sys.stderr)
|
|
196
|
+
return 2
|
|
197
|
+
regressions = compare_to_baseline(report.to_json_dict(), baseline, tolerance=args.tolerance)
|
|
198
|
+
if regressions:
|
|
199
|
+
print("\nBASELINE REGRESSIONS:", file=sys.stderr)
|
|
200
|
+
for r in regressions:
|
|
201
|
+
print(f" - {r}", file=sys.stderr)
|
|
202
|
+
exit_code = exit_code or 1
|
|
203
|
+
return exit_code
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_failover(args: argparse.Namespace) -> int:
|
|
207
|
+
import asyncio
|
|
208
|
+
import json
|
|
209
|
+
import os
|
|
210
|
+
import socket
|
|
211
|
+
from pathlib import Path
|
|
212
|
+
|
|
213
|
+
from harness.load.failover import FailoverError, FailoverPorts, run_failover_load
|
|
214
|
+
from harness.load.profile import LoadProfileError, get_profile
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
profile = get_profile(args.failover)
|
|
218
|
+
except LoadProfileError as exc:
|
|
219
|
+
print(f"bad profile: {exc}", file=sys.stderr)
|
|
220
|
+
return 2
|
|
221
|
+
if profile.failover is None:
|
|
222
|
+
print(
|
|
223
|
+
f"profile {profile.name!r} has no [load.failover] table — not a failover profile",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
return 2
|
|
227
|
+
# A failover needs a SHARED server DB (SQLite is single-file/single-node — it can't cluster).
|
|
228
|
+
backend = os.environ.get("MEFOR_STORE_BACKEND", "").strip().lower()
|
|
229
|
+
if backend not in ("postgres", "sqlserver"):
|
|
230
|
+
print(
|
|
231
|
+
"failover needs a shared server DB: set MEFOR_STORE_BACKEND=postgres|sqlserver (+ the "
|
|
232
|
+
f"MEFOR_STORE_* connection env); got {backend or '(unset → sqlite)'!r}",
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
return 2
|
|
236
|
+
|
|
237
|
+
def _two_free_ports() -> tuple[int, int]:
|
|
238
|
+
# Hold BOTH sockets open while reading their ports so the kernel can't hand the same ephemeral
|
|
239
|
+
# port back to the second bind (the close->rebind race that would launch both nodes on one --port).
|
|
240
|
+
s1, s2 = socket.socket(), socket.socket()
|
|
241
|
+
for s in (s1, s2):
|
|
242
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
243
|
+
s.bind(("127.0.0.1", 0))
|
|
244
|
+
try:
|
|
245
|
+
return int(s1.getsockname()[1]), int(s2.getsockname()[1])
|
|
246
|
+
finally:
|
|
247
|
+
s1.close()
|
|
248
|
+
s2.close()
|
|
249
|
+
|
|
250
|
+
base = args.inbound_base_port
|
|
251
|
+
api_a, api_b = _two_free_ports()
|
|
252
|
+
ports = FailoverPorts(
|
|
253
|
+
inbound_adt=base,
|
|
254
|
+
inbound_results=base + 1,
|
|
255
|
+
inbound_other=base + 2,
|
|
256
|
+
sink=args.sink_port,
|
|
257
|
+
sink_count=args.sink_ports,
|
|
258
|
+
api_a=api_a,
|
|
259
|
+
api_b=api_b,
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
report = asyncio.run(
|
|
263
|
+
run_failover_load(profile, ports=ports, db_backend=args.db_backend or backend)
|
|
264
|
+
)
|
|
265
|
+
except FailoverError as exc:
|
|
266
|
+
print(f"failover setup failed: {exc}", file=sys.stderr)
|
|
267
|
+
return 2
|
|
268
|
+
except KeyboardInterrupt:
|
|
269
|
+
print("interrupted", file=sys.stderr)
|
|
270
|
+
return 3
|
|
271
|
+
|
|
272
|
+
print(report.render_console())
|
|
273
|
+
if args.report_json:
|
|
274
|
+
Path(args.report_json).write_text(
|
|
275
|
+
json.dumps(report.to_json_dict(), indent=2), encoding="utf-8"
|
|
276
|
+
)
|
|
277
|
+
return report.exit_code
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _launch_gui() -> int:
|
|
281
|
+
from PySide6.QtWidgets import QApplication
|
|
282
|
+
|
|
283
|
+
from harness.window import HarnessWindow
|
|
284
|
+
|
|
285
|
+
app = QApplication(sys.argv)
|
|
286
|
+
window = HarnessWindow()
|
|
287
|
+
window.resize(1100, 750)
|
|
288
|
+
window.show()
|
|
289
|
+
return app.exec()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Windows Server 2025 on-server acceptance harness.
|
|
4
|
+
|
|
5
|
+
Turns ``docs/testing/WIN2025-TEST-MATRIX.md`` into an executable runner: each matrix row is bound to
|
|
6
|
+
a *probe* (a live environment/host check), a set of *pytest node ids* (the existing suites that
|
|
7
|
+
already assert that row, run against whatever backends ``MEFOR_*`` env makes reachable), a *harness*
|
|
8
|
+
command, or a *manual* step. The runner executes what it can, leaves the rest clearly marked MANUAL
|
|
9
|
+
(never faked green), and emits a PASS/FAIL/SKIP/MANUAL report — optionally writing the verdict back
|
|
10
|
+
into the matrix spreadsheet's Status column.
|
|
11
|
+
|
|
12
|
+
Run it with ``python -m harness.acceptance`` (see ``--help``). It imports no PySide6 on the headless
|
|
13
|
+
path, so it runs on the server over a remote session as well as the console host.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from harness.acceptance.matrix import MATRIX, Coverage, MatrixRow, Status
|
|
19
|
+
from harness.acceptance.runner import RowResult, run_matrix
|
|
20
|
+
|
|
21
|
+
__all__ = ["MATRIX", "Coverage", "MatrixRow", "Status", "RowResult", "run_matrix"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""CLI for the WIN2025 acceptance harness.
|
|
4
|
+
|
|
5
|
+
``python -m harness.acceptance`` → run probes + the backing pytest suites, print
|
|
6
|
+
a per-row summary, exit 0 (no FAIL/ERROR) / 1.
|
|
7
|
+
``python -m harness.acceptance --no-pytest`` → probes only (fast host check; no suite run).
|
|
8
|
+
``python -m harness.acceptance --section A,B`` → run only those matrix sections.
|
|
9
|
+
``python -m harness.acceptance --report-md r.md --report-csv r.csv``
|
|
10
|
+
→ also write Markdown / CSV reports.
|
|
11
|
+
``python -m harness.acceptance --xlsx WIN2025-TEST-MATRIX.xlsx``
|
|
12
|
+
→ write each verdict back into the workbook's
|
|
13
|
+
Status column (needs openpyxl).
|
|
14
|
+
|
|
15
|
+
Exit codes: 0 = no FAIL/ERROR (MANUAL/SKIP are not failures), 1 = at least one FAIL/ERROR, 2 = bad
|
|
16
|
+
usage / write error.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from harness.acceptance.matrix import SECTIONS
|
|
26
|
+
from harness.acceptance.report import (
|
|
27
|
+
exit_code,
|
|
28
|
+
render_console,
|
|
29
|
+
render_csv,
|
|
30
|
+
render_markdown,
|
|
31
|
+
write_xlsx_status,
|
|
32
|
+
)
|
|
33
|
+
from harness.acceptance.runner import run_matrix
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
38
|
+
try:
|
|
39
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
40
|
+
except (AttributeError, ValueError, OSError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="harness.acceptance",
|
|
45
|
+
description="Run the Windows Server 2025 on-server acceptance matrix.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--no-pytest",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="run probes only; skip the backing pytest suites (PYTEST rows report SKIP)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--section", help="comma-separated section letters to run (e.g. A,B,C); default = all"
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--report-md", help="write the Markdown report to this path")
|
|
56
|
+
parser.add_argument("--report-csv", help="write the CSV report to this path")
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--xlsx", help="write each verdict back into this matrix workbook's Status column"
|
|
59
|
+
)
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
include_sections = None
|
|
63
|
+
if args.section:
|
|
64
|
+
include_sections = [s.strip().upper() for s in args.section.split(",") if s.strip()]
|
|
65
|
+
unknown = [s for s in include_sections if s not in SECTIONS]
|
|
66
|
+
if unknown:
|
|
67
|
+
print(
|
|
68
|
+
f"unknown section(s): {', '.join(unknown)}; choices: {', '.join(SECTIONS)}",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
results = run_matrix(run_pytest=not args.no_pytest, include_sections=include_sections)
|
|
74
|
+
|
|
75
|
+
print(render_console(results))
|
|
76
|
+
|
|
77
|
+
if args.report_md:
|
|
78
|
+
Path(args.report_md).write_text(render_markdown(results), encoding="utf-8")
|
|
79
|
+
if args.report_csv:
|
|
80
|
+
Path(args.report_csv).write_text(render_csv(results), encoding="utf-8")
|
|
81
|
+
if args.xlsx:
|
|
82
|
+
try:
|
|
83
|
+
n = write_xlsx_status(results, Path(args.xlsx))
|
|
84
|
+
except (RuntimeError, OSError) as exc:
|
|
85
|
+
print(f"xlsx write-back failed: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
print(f" wrote {n} verdict(s) into {args.xlsx}", file=sys.stderr)
|
|
88
|
+
|
|
89
|
+
return exit_code(results)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
raise SystemExit(main())
|