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.
Files changed (56) hide show
  1. harness/README.md +101 -0
  2. harness/__main__.py +293 -0
  3. harness/acceptance/__init__.py +21 -0
  4. harness/acceptance/__main__.py +93 -0
  5. harness/acceptance/matrix.py +528 -0
  6. harness/acceptance/probes.py +220 -0
  7. harness/acceptance/report.py +147 -0
  8. harness/acceptance/runner.py +176 -0
  9. harness/compose.py +225 -0
  10. harness/config/coverage.py +80 -0
  11. harness/config/load/_shape.py +134 -0
  12. harness/config/load/graph.py +90 -0
  13. harness/file_panel.py +250 -0
  14. harness/file_transport.py +165 -0
  15. harness/load/__init__.py +17 -0
  16. harness/load/corpus.py +176 -0
  17. harness/load/correlator.py +73 -0
  18. harness/load/enginepoll.py +162 -0
  19. harness/load/failover.py +815 -0
  20. harness/load/failover_track.py +143 -0
  21. harness/load/governor.py +109 -0
  22. harness/load/ids.py +42 -0
  23. harness/load/metrics.py +219 -0
  24. harness/load/profile.py +629 -0
  25. harness/load/profiles/README.md +33 -0
  26. harness/load/profiles/closed-loop.toml +100 -0
  27. harness/load/profiles/failover.toml +55 -0
  28. harness/load/profiles/fanout-baseline.toml +92 -0
  29. harness/load/profiles/malformed-load.toml +44 -0
  30. harness/load/profiles/reference.toml +100 -0
  31. harness/load/profiles/smoke-sqlserver.toml +42 -0
  32. harness/load/profiles/smoke.toml +36 -0
  33. harness/load/profiles/soak.toml +45 -0
  34. harness/load/profiles/spike-burst.toml +50 -0
  35. harness/load/profiles/sustained-overload.toml +51 -0
  36. harness/load/profiles/writeamp.toml +41 -0
  37. harness/load/report.py +582 -0
  38. harness/load/runner.py +166 -0
  39. harness/load/sender.py +322 -0
  40. harness/load/sink.py +136 -0
  41. harness/mllp.py +271 -0
  42. harness/monitor.py +547 -0
  43. harness/receive.py +137 -0
  44. harness/reconcile/__init__.py +49 -0
  45. harness/reconcile/__main__.py +149 -0
  46. harness/reconcile/capture.py +150 -0
  47. harness/reconcile/compare.py +179 -0
  48. harness/reconcile/normalize.py +234 -0
  49. harness/reconcile/report.py +73 -0
  50. harness/scenarios.py +195 -0
  51. harness/send.py +175 -0
  52. harness/window.py +52 -0
  53. messagefoundry_harness-0.2.4.dist-info/METADATA +56 -0
  54. messagefoundry_harness-0.2.4.dist-info/RECORD +56 -0
  55. messagefoundry_harness-0.2.4.dist-info/WHEEL +4 -0
  56. 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())