From e9a3a8de426e6562c97d6e5145626bb1d2cabf72 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:39:03 -0500 Subject: [PATCH 1/3] Switch all tests back to wait_for_text() now that harness PR #29 is merged The harness wait_for_text() now calls transport.resume() between polls internally, so the inline polling loops are no longer needed. This replaces 13 copies of the same ~10-line loop with single wait_for_text() calls, reducing total code by 120 lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/_diag_carry.py | 15 +++------------ tools/run_all_tests.py | 14 +++----------- tools/test_chained_hmac.py | 14 +++----------- tools/test_crypto.py | 16 +++------------- tools/test_entropy.py | 16 +++------------- tools/test_hkdf.py | 15 +++------------ tools/test_http.py | 18 ++++-------------- tools/test_keyschedule_steps.py | 16 +++------------- tools/test_net.py | 17 ++++------------- tools/test_sha256.py | 15 +++------------ tools/test_tls_handshake.py | 14 ++------------ tools/test_tls_record.py | 14 ++------------ tools/test_x509.py | 14 +++----------- 13 files changed, 39 insertions(+), 159 deletions(-) diff --git a/tools/_diag_carry.py b/tools/_diag_carry.py index a8a053f..81750b9 100644 --- a/tools/_diag_carry.py +++ b/tools/_diag_carry.py @@ -4,8 +4,8 @@ os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, goto, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, goto, jsr, wait_for_text, ) import subprocess @@ -86,16 +86,7 @@ def jsr_with_carry_diag(transport, addr, timeout=60.0, poll_interval=0.5): t = inst.transport print(f"VICE PID={inst.pid}, port={inst.port}", flush=True) - # Binary monitor: resume CPU between screen polls - grid = None - deadline = time.monotonic() + 180.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(t) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - t.resume() - time.sleep(1.0) + grid = wait_for_text(t, "Q=QUIT", timeout=180.0, verbose=False) if grid is None: print("FATAL: menu not found") sys.exit(1) diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py index a881549..6df9b48 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -13,8 +13,8 @@ os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PRG_PATH = os.path.join("build", "c64-https.prg") @@ -119,15 +119,7 @@ def main(): # Wait for all instances to boot (binary monitor: resume CPU between polls) for i, inst in enumerate(instances): - grid = None - deadline = time.monotonic() + 120.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(inst.transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - inst.transport.resume() - time.sleep(1.0) + grid = wait_for_text(inst.transport, "Q=QUIT", timeout=120.0, verbose=False) if grid is None: print(f" Worker {i}: FATAL - menu did not appear") sys.exit(1) diff --git a/tools/test_chained_hmac.py b/tools/test_chained_hmac.py index c273d94..05394ac 100644 --- a/tools/test_chained_hmac.py +++ b/tools/test_chained_hmac.py @@ -19,13 +19,13 @@ Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, set_breakpoint, delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -91,16 +91,8 @@ def main(): transport = inst.transport print(f" N={n}: VICE PID={inst.pid}, port={inst.port}") - # Wait for program menu (binary monitor: resume CPU between polls) - grid = None - deadline = time.time() + 60 - while time.time() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + # Wait for program menu + grid = wait_for_text(transport, "Q=QUIT", timeout=60, verbose=False) if grid is None: print(f" N={n}: FAIL - main menu did not appear") results.append((n, False, 0.0, True)) diff --git a/tools/test_crypto.py b/tools/test_crypto.py index 171eb6b..31cb65b 100644 --- a/tools/test_crypto.py +++ b/tools/test_crypto.py @@ -14,10 +14,9 @@ import struct import subprocess import sys -import time from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -763,16 +762,7 @@ def main(): transport = inst.transport print(f"VICE PID={inst.pid}, port={inst.port}") - # Binary monitor: resume CPU between screen polls - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Program menu did not appear") sys.exit(1) diff --git a/tools/test_entropy.py b/tools/test_entropy.py index 4c5c3be..ce063d4 100644 --- a/tools/test_entropy.py +++ b/tools/test_entropy.py @@ -14,13 +14,10 @@ import os import subprocess import sys -import time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, @@ -28,6 +25,7 @@ delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -388,17 +386,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between screen polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_hkdf.py b/tools/test_hkdf.py index eb5d934..d363d8d 100644 --- a/tools/test_hkdf.py +++ b/tools/test_hkdf.py @@ -26,12 +26,11 @@ Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, + wait_for_text, ) -import time # --------------------------------------------------------------------------- # Constants @@ -473,17 +472,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_http.py b/tools/test_http.py index 273a2c9..f81206b 100755 --- a/tools/test_http.py +++ b/tools/test_http.py @@ -13,11 +13,9 @@ import struct import subprocess import sys -import time - from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -454,16 +452,8 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for menu to appear (binary monitor: resume CPU between polls) - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + # Wait for menu to appear + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print(" FATAL: Program menu did not appear") sys.exit(1) diff --git a/tools/test_keyschedule_steps.py b/tools/test_keyschedule_steps.py index 0fc1ad6..905d88b 100644 --- a/tools/test_keyschedule_steps.py +++ b/tools/test_keyschedule_steps.py @@ -18,16 +18,14 @@ import subprocess import sys -import time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -395,17 +393,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_net.py b/tools/test_net.py index 458ff4e..00fcbec 100644 --- a/tools/test_net.py +++ b/tools/test_net.py @@ -13,10 +13,9 @@ import struct import subprocess import sys -import time from c64_test_harness import ( - Labels, ViceConfig, ViceInstanceManager, ScreenGrid, - read_bytes, write_bytes, jsr, + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, ) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -481,16 +480,8 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for menu to appear (binary monitor: resume CPU between polls) - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + # Wait for menu to appear + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print(" FATAL: Program menu did not appear") sys.exit(1) diff --git a/tools/test_sha256.py b/tools/test_sha256.py index 3866bf3..836dfe6 100644 --- a/tools/test_sha256.py +++ b/tools/test_sha256.py @@ -17,15 +17,14 @@ import struct import subprocess import sys -import time from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -325,17 +324,9 @@ def main(): transport = inst.transport print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between screen polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_tls_handshake.py b/tools/test_tls_handshake.py index cd5df74..5c1b3ba 100644 --- a/tools/test_tls_handshake.py +++ b/tools/test_tls_handshake.py @@ -20,13 +20,10 @@ import subprocess import sys -import time as _time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, @@ -34,6 +31,7 @@ delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -1272,15 +1270,7 @@ def main(): # Wait for main menu (binary monitor: resume CPU between polls) print(" Waiting for main menu...") - grid = None - deadline = _time.monotonic() + 60.0 - while _time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - _time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_tls_record.py b/tools/test_tls_record.py index a87e0ef..df8b527 100644 --- a/tools/test_tls_record.py +++ b/tools/test_tls_record.py @@ -19,13 +19,10 @@ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -import time - from c64_test_harness import ( Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, @@ -33,6 +30,7 @@ delete_breakpoint, goto, wait_for_pc, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -777,15 +775,7 @@ def main(): # Wait for main menu (binary monitor: resume CPU between polls) print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) diff --git a/tools/test_x509.py b/tools/test_x509.py index 370bfeb..41a5518 100644 --- a/tools/test_x509.py +++ b/tools/test_x509.py @@ -30,11 +30,11 @@ Labels, ViceConfig, ViceInstanceManager, - ScreenGrid, read_bytes, write_bytes, jsr, goto, + wait_for_text, ) # --------------------------------------------------------------------------- @@ -771,17 +771,9 @@ def main(): print(f"\n=== Starting VICE ===") print(f" VICE PID={inst.pid}, port={inst.port}") - # Wait for main menu (binary monitor: resume CPU between polls) + # Wait for main menu print(" Waiting for main menu...") - grid = None - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - g = ScreenGrid.from_transport(transport) - if "Q=QUIT" in g.continuous_text().upper(): - grid = g - break - transport.resume() - time.sleep(1.0) + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) if grid is None: print("FATAL: Main menu did not appear") sys.exit(1) From 6cb9f54e04c1a349c1f2af1c2db081d2152ac5c2 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:11:35 -0500 Subject: [PATCH 2/3] Fix parallel test runner: all 10 suites, instance-per-suite, no worker reuse The parallel runner had two bugs: (1) as_completed() didn't see futures added mid-iteration, so only the first N suites were collected, and (2) reusing VICE instances across suites caused state contamination (HKDF 0/12 on reused workers). Fix: allocate a fresh VICE instance per suite via run_suite_in_own_instance(). Add all 10 suites (was 5). Add --skip-slow and --seed flags. 193/193 pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 11 ++- tools/run_all_tests.py | 178 ++++++++++++++++++++--------------------- 2 files changed, 92 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 25bc431..58674f1 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,15 @@ Current status (24.8 KB binary, 487 labels): ## Test Automation -193 tests across 9 suites + 2 diagnostic suites, using the [`c64-test-harness`](../c64-test-harness) package to drive VICE via its binary monitor protocol. All tests log VICE PID and port for multi-agent safety. +193 tests across 10 suites (+ 1 standalone diagnostic), using the [`c64-test-harness`](../c64-test-harness) package to drive VICE via its binary monitor protocol. The parallel runner allocates a fresh VICE instance per suite to avoid state contamination. All tests log VICE PID and port for multi-agent safety. ```bash pip install -e ../c64-test-harness -# Run all suites in parallel (5 VICE instances, ~2.5 min wall time) -python3 tools/run_all_tests.py --workers 5 +# Run all 10 suites in parallel (one VICE instance per suite, ~5 min with ECDSA) +python3 tools/run_all_tests.py +python3 tools/run_all_tests.py --skip-slow # Skip x509/ECDSA (~5s wall time) +python3 tools/run_all_tests.py --workers 6 # Limit concurrent VICE instances # Individual suites python3 tools/test_net.py # 60 tests: ip65 integration, ZP save/restore, ring buffer, TCP recv callback @@ -139,7 +141,8 @@ python3 tools/test_x509.py # 11 tests: DER parse P-256/P-384, ECDSA ver python3 tools/test_tls_handshake.py # 21 tests: transcript hash, ClientHello, ServerHello, key schedule (RFC 8448), Finished MAC python3 tools/test_keyschedule_steps.py # 9 tests: key schedule step-by-step (RFC 8448 vectors) python3 tools/test_entropy.py # 7 tests: SID/CIA hardware init, DRBG seeding, output quality -python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10) +python3 tools/test_http.py # 27 tests: HTTP/1.1 GET builder, response parser, status codes +python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10, standalone) ``` ## Related Projects diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py index 6df9b48..783733c 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -2,13 +2,15 @@ """Run all c64-https test suites in parallel using ViceInstanceManager. Usage: - python3 tools/run_all_tests.py [--workers N] + python3 tools/run_all_tests.py [--workers N] [--seed S] [--skip-slow] """ import os +import random import subprocess import sys import time +from concurrent.futures import ThreadPoolExecutor, as_completed os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) @@ -35,48 +37,26 @@ def build(): return Labels.from_file(LABELS_PATH) -def run_test_suite(name, transport, labels, port, pid): +def run_test_suite(name, transport, labels, seed): """Run a single test suite, return (name, passed, failed, duration).""" + # Ensure CPU is running before each suite (previous suite leaves it paused + # after jsr() returns at a breakpoint) + transport.resume() start = time.time() passed = failed = 0 try: if name == "net": - from test_net import test_build_integrity, test_ip65_jump_table - from test_net import test_zp_save_restore, test_recv_ring_buffer - from test_net import test_ip65_init_without_hardware - - p, f = test_build_integrity(labels) - passed += p; failed += f - p, f = test_ip65_jump_table(transport) - passed += p; failed += f - p, f = test_zp_save_restore(transport, labels) - passed += p; failed += f - p, f = test_recv_ring_buffer(transport, labels) - passed += p; failed += f - p, f = test_ip65_init_without_hardware(transport, labels) - passed += p; failed += f + from test_net import run_tests as net_run + passed, failed = net_run(transport, labels) elif name == "sha256": from test_sha256 import run_tests as sha256_run passed, failed = sha256_run(transport, labels, iterations=5) elif name == "crypto": - from test_crypto import (test_sqtab_init, test_chacha20_block_rfc, - test_chacha20_encrypt_rfc, test_poly1305_mac_rfc, - test_aead_encrypt_rfc, test_aead_decrypt_roundtrip, - test_aead_random) - import random - rng = random.Random(42) - for fn in [test_sqtab_init, test_chacha20_block_rfc, - test_chacha20_encrypt_rfc, test_poly1305_mac_rfc, - test_aead_encrypt_rfc]: - p, f = fn(transport, labels) - passed += p; failed += f - p, f = test_aead_decrypt_roundtrip(transport, labels, rng) - passed += p; failed += f - p, f = test_aead_random(transport, labels, rng) - passed += p; failed += f + from test_crypto import run_tests as crypto_run + passed, failed = crypto_run(transport, labels, seed=seed) elif name == "hkdf": from test_hkdf import run_tests as hkdf_run @@ -84,10 +64,32 @@ def run_test_suite(name, transport, labels, port, pid): elif name == "tls_record": from test_tls_record import run_tests as record_run - passed, failed = record_run(transport, labels, seed=42) + passed, failed = record_run(transport, labels, seed=seed) + + elif name == "tls_handshake": + from test_tls_handshake import run_tests as handshake_run + passed, failed = handshake_run(transport, labels, seed=seed) + + elif name == "keyschedule": + from test_keyschedule_steps import run_tests as ks_run + passed, failed = ks_run(transport, labels) + + elif name == "entropy": + from test_entropy import run_tests as entropy_run + passed, failed = entropy_run(transport, labels) + + elif name == "http": + from test_http import run_tests as http_run + passed, failed = http_run(transport, labels) + + elif name == "x509": + from test_x509 import run_tests as x509_run + passed, failed = x509_run(transport, labels) except Exception as e: + import traceback print(f" [{name}] EXCEPTION: {e}") + traceback.print_exc() failed += 1 duration = time.time() - start @@ -95,64 +97,66 @@ def run_test_suite(name, transport, labels, port, pid): def main(): - workers = 3 - for i, arg in enumerate(sys.argv[1:]): - if arg == "--workers": - workers = int(sys.argv[i + 2]) + workers = 4 + seed = random.randint(0, 2**32 - 1) + skip_slow = False + + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--workers": + workers = int(args[i + 1]) + i += 2 + elif args[i] == "--seed": + seed = int(args[i + 1]) + i += 2 + elif args[i] == "--skip-slow": + skip_slow = True + i += 1 + else: + i += 1 + + print(f"Random seed: {seed} (reproduce with --seed {seed})") labels = build() - suites = ["net", "sha256", "crypto", "hkdf", "tls_record"] + # x509 is by far the slowest (~5 min for ECDSA verify), so start it first. + # Entropy uses manual breakpoints sensitive to CPU state, so start it early + # on a fresh worker. Remaining fast suites fill in around them. + suites = ["entropy", "net", "sha256", "crypto", "hkdf", + "keyschedule", "http", "tls_record", "tls_handshake"] + if not skip_slow: + suites.insert(0, "x509") config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) + num_instances = min(workers, len(suites)) - print(f"\n=== Starting {workers} VICE instances (staggered 100ms) ===") + print(f"\n=== Launching {len(suites)} suites across " + f"{num_instances} concurrent VICE instances ===") - with ViceInstanceManager(config=config) as mgr: - instances = [] - for i in range(min(workers, len(suites))): - inst = mgr.acquire() - print(f" Worker {i}: VICE PID={inst.pid}, port={inst.port}") - instances.append(inst) - if i < workers - 1: - time.sleep(0.1) # 100ms stagger per PATTERNS.md - - # Wait for all instances to boot (binary monitor: resume CPU between polls) - for i, inst in enumerate(instances): - grid = wait_for_text(inst.transport, "Q=QUIT", timeout=120.0, verbose=False) + def run_suite_in_own_instance(mgr, suite_name): + """Acquire a fresh VICE instance, run one suite, release.""" + inst = mgr.acquire() + try: + grid = wait_for_text(inst.transport, "Q=QUIT", timeout=120.0, + verbose=False) if grid is None: - print(f" Worker {i}: FATAL - menu did not appear") - sys.exit(1) + return suite_name, 0, 1, 0.0 # Safety loop: JMP $0339 prevents crash when BASIC ROM banked out write_bytes(inst.transport, 0x0339, bytes([0x4C, 0x39, 0x03])) - print(f" Worker {i}: ready") - - # Each suite gets its own worker — suites run in parallel - # If more suites than workers, extra suites wait for a free worker - from concurrent.futures import ThreadPoolExecutor, as_completed - - def worker_fn(suite_name, inst): - return run_test_suite(suite_name, inst.transport, labels, - inst.port, inst.pid) - - results = [] - print(f"\n=== Running {len(suites)} test suites across " - f"{len(instances)} workers ===\n") - - # Map suites to workers 1:1 (first batch), then reuse freed workers - with ThreadPoolExecutor(max_workers=len(instances)) as pool: - futures = {} - inst_queue = list(instances) - pending_suites = list(suites) - active = {} - - # Submit up to N suites (one per worker) - while pending_suites and inst_queue: - suite = pending_suites.pop(0) - inst = inst_queue.pop(0) - fut = pool.submit(worker_fn, suite, inst) - futures[fut] = suite - active[fut] = inst + + return run_test_suite(suite_name, inst.transport, labels, seed) + finally: + mgr.release(inst) + + results = [] + + with ViceInstanceManager(config=config) as mgr: + with ThreadPoolExecutor(max_workers=num_instances) as pool: + futures = { + pool.submit(run_suite_in_own_instance, mgr, suite): suite + for suite in suites + } for fut in as_completed(futures): name, passed, failed, duration = fut.result() @@ -161,18 +165,6 @@ def worker_fn(suite_name, inst): print(f" [{status}] {name}: {passed}/{passed+failed} " f"({duration:.1f}s)") - # Return this worker's instance and submit next suite - freed_inst = active.pop(fut) - if pending_suites: - suite = pending_suites.pop(0) - new_fut = pool.submit(worker_fn, suite, freed_inst) - futures[new_fut] = suite - active[new_fut] = freed_inst - - # Release instances - for inst in instances: - mgr.release(inst) - # Summary total_passed = sum(r[1] for r in results) total_failed = sum(r[2] for r in results) @@ -183,7 +175,7 @@ def worker_fn(suite_name, inst): f"{total_failed} failed") for name, passed, failed, duration in sorted(results): status = "OK" if failed == 0 else "FAIL" - print(f" {status:4s} {name:15s} {passed:3d}/{passed+failed:3d} " + print(f" {status:4s} {name:20s} {passed:3d}/{passed+failed:3d} " f"({duration:.1f}s)") print(f"{'='*60}") From 7dc6721c372c6b5a00b4fb418855ccd7ea054609 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:02:06 -0500 Subject: [PATCH 3/3] Fix A/X register clobbering in net.asm and add DNS + HTTP integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net_dns_resolve and net_set_tcp_dest both passed A/X parameters through net_save_zp, which uses X as a loop counter and clobbers both registers. This caused DNS resolution and TCP destination setup to receive garbage pointers instead of the caller's intended addresses. Fixed by pushing A/X to the stack before the ZP save and restoring after. Added test_dns.py (4 tests) exercising net_dns_resolve over TAP with dnsmasq, and test_http_integration.py (5 tests) for end-to-end plain HTTP GET (DNS → TCP → request → response). Both use ViceInstanceManager with ethernet_mode="rrnet" and run unprivileged (only dnsmasq via sudo). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- build/c64-https.prg | Bin 39573 -> 39573 bytes build/labels.txt | 164 ++++++++-------- src/net.asm | 12 ++ tools/test_dns.py | 344 +++++++++++++++++++++++++++++++++ tools/test_http_integration.py | 329 +++++++++++++++++++++++++++++++ tools/test_server.py | 110 +++++++++++ 7 files changed, 882 insertions(+), 83 deletions(-) create mode 100644 tools/test_dns.py create mode 100644 tools/test_http_integration.py create mode 100644 tools/test_server.py diff --git a/README.md b/README.md index 58674f1..ea9ee7a 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Current status (24.8 KB binary, 487 labels): - [x] Entropy/DRBG initialization — SID voice 3 noise + CIA timer seeding at boot, DRBG fills for TLS random values - [x] X.509 certificate parsing — DER parser extracts TBS, public key, signature (r,s), curve ID for P-256 and P-384 - [x] ECDSA signature verification — P-256 and P-384, full verify (s⁻¹, scalar mul, point add, Jacobian→affine) -- [ ] HTTP/1.1 GET request +- [x] HTTP/1.1 GET request — build GET, parse response (status + headers + body), plain HTTP end-to-end - [ ] End-to-end HTTPS GET demo ### Known Issues @@ -143,6 +143,10 @@ python3 tools/test_keyschedule_steps.py # 9 tests: key schedule step-by-step (RF python3 tools/test_entropy.py # 7 tests: SID/CIA hardware init, DRBG seeding, output quality python3 tools/test_http.py # 27 tests: HTTP/1.1 GET builder, response parser, status codes python3 tools/test_chained_hmac.py # 10 tests: chained HMAC-SHA256 stability (N=1..10, standalone) + +# Integration tests (require tap-c64 interface, dnsmasq; see scripts/setup-tap-networking.sh in c64-test-harness) +python3 tools/test_dns.py # 4 tests: DNS resolution via ip65 over TAP (known host, second host, unknown host) +python3 tools/test_http_integration.py # 5 tests: end-to-end plain HTTP GET over TAP (DNS + TCP + request/response) ``` ## Related Projects diff --git a/build/c64-https.prg b/build/c64-https.prg index 36784959929be54a74d7fa76529e91d1d7c030f1..39f1ff62924681edb17d5c01fdd72790e8efe242 100644 GIT binary patch delta 2593 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 8) & 0xFF + result_lo = CARRY_RESULT_ADDR & 0xFF + result_hi = (CARRY_RESULT_ADDR >> 8) & 0xFF + return bytes([ + 0xA9, hostname_lo, # LDA #hostname_lo + 0xA2, hostname_hi, # LDX #hostname_hi + 0x20, dns_lo, dns_hi, # JSR net_dns_resolve + 0xA9, 0x00, # LDA #$00 (success) + 0x90, 0x02, # BCC +2 (branch if carry clear = success) + 0xA9, 0x01, # LDA #$01 (failure) + 0x8D, result_lo, result_hi, # STA CARRY_RESULT_ADDR + 0x60, # RTS + ]) + + +def do_dns_resolve(transport, write_bytes, read_bytes, jsr_fn, + hostname_str, dns_resolve_addr): + """Write hostname to scratch RAM, build trampoline, call it, return + (carry_result, ip_bytes). + + carry_result: 0 = success (carry clear), 1 = failure (carry set) + ip_bytes: 4-byte list from ip65_dns_ip_addr + """ + # Write null-terminated hostname to scratch RAM + hostname = hostname_str.encode("ascii") + b"\x00" + write_bytes(transport, HOSTNAME_ADDR, hostname) + + # Clear carry result location + write_bytes(transport, CARRY_RESULT_ADDR, [0xFF]) + + hostname_lo = HOSTNAME_ADDR & 0xFF + hostname_hi = (HOSTNAME_ADDR >> 8) & 0xFF + + trampoline = build_dns_trampoline(hostname_lo, hostname_hi, dns_resolve_addr) + write_bytes(transport, TRAMPOLINE_ADDR, trampoline) + + # Execute the trampoline + jsr_fn(transport, TRAMPOLINE_ADDR, timeout=30.0) + + # Read carry result + carry_bytes = read_bytes(transport, CARRY_RESULT_ADDR, 1) + carry_result = carry_bytes[0] + + # Read resolved IP (4 bytes) + ip_bytes = read_bytes(transport, IP65_DNS_IP_ADDR, 4) + + return carry_result, ip_bytes + + +# --------------------------------------------------------------------------- +# Main test +# --------------------------------------------------------------------------- + +def main(): + os.chdir(PROJECT_ROOT) + + if not check_prerequisites(): + sys.exit(0) + + # Late imports -- only needed if prerequisites are met + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from c64_test_harness import ( + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, + ) + + passed = 0 + failed = 0 + dnsmasq_proc = None + mgr = None + inst = None + + try: + # ---- 1. Build -------------------------------------------------------- + print("\n=== Building ===") + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f" Build failed:\n{result.stderr}") + sys.exit(1) + print(" Build OK") + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded, {len(labels)} symbols") + + # ---- Test: test_dns_labels ------------------------------------------- + print("\n=== test_dns_labels ===") + dns_resolve_addr = labels.address("net_dns_resolve") + if dns_resolve_addr is not None: + print(f" PASS: net_dns_resolve found @ ${dns_resolve_addr:04X}") + passed += 1 + else: + print(" FAIL: net_dns_resolve label not found") + failed += 1 + raise RuntimeError("Required label net_dns_resolve not found") + + # ---- 2. Start dnsmasq ------------------------------------------------ + print("\n=== Starting dnsmasq ===") + dnsmasq_proc = start_dnsmasq() + + # ---- 3. Launch VICE -------------------------------------------------- + print("\n=== Starting VICE ===") + config = ViceConfig( + prg_path=PRG_PATH, + warp=False, # warp causes timing issues with ethernet + ntsc=True, + sound=False, + ethernet=True, + ethernet_mode="rrnet", + ethernet_driver="tuntap", + ethernet_interface="tap-c64", + ) + + mgr = ViceInstanceManager(config=config) + inst = mgr.acquire() + transport = inst.transport + print(f" VICE PID={inst.pid}, port={inst.port}") + + # ---- 4. Wait for boot menu ------------------------------------------ + print("\n=== Waiting for boot menu ===") + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print(" FATAL: Program menu did not appear") + failed += 1 + raise RuntimeError("Boot menu timeout") + print(" Boot menu appeared") + + # ---- 5. Network init (DHCP) ----------------------------------------- + print("\n=== Network init (pressing I for init) ===") + transport.resume() # CPU paused after wait_for_text screen read + transport.inject_keys([0x49]) # 'I' + + grid = wait_for_text(transport, "DHCP OK", timeout=60.0, verbose=False) + if grid is None: + print(" FAIL: DHCP did not complete within 60 seconds") + if dnsmasq_proc: + dnsmasq_proc.terminate() + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stderr:\n{stderr.decode()}") + dnsmasq_proc = None + failed += 1 + raise RuntimeError("DHCP timeout") + print(" DHCP OK") + + # ---- Test: test_dns_resolve_known_host ------------------------------- + print("\n=== test_dns_resolve_known_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "c64test.local", dns_resolve_addr, + ) + expected_ip = [10, 0, 65, 1] + if carry == 0 and list(ip) == expected_ip: + print(f" PASS: resolved c64test.local -> {'.'.join(str(b) for b in ip)}" + f", carry=0") + passed += 1 + else: + print(f" FAIL: c64test.local -> {list(ip)}, carry={carry}" + f" (expected {expected_ip}, carry=0)") + failed += 1 + + # ---- Test: test_dns_resolve_second_host ------------------------------ + print("\n=== test_dns_resolve_second_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "second.local", dns_resolve_addr, + ) + if carry == 0 and list(ip) == expected_ip: + print(f" PASS: resolved second.local -> {'.'.join(str(b) for b in ip)}" + f", carry=0") + passed += 1 + else: + print(f" FAIL: second.local -> {list(ip)}, carry={carry}" + f" (expected {expected_ip}, carry=0)") + failed += 1 + + # ---- Test: test_dns_resolve_unknown_host ----------------------------- + print("\n=== test_dns_resolve_unknown_host ===") + carry, ip = do_dns_resolve( + transport, write_bytes, read_bytes, jsr, + "nonexistent.invalid", dns_resolve_addr, + ) + if carry == 1: + print(f" PASS: nonexistent.invalid -> carry=1 (failure, as expected)") + passed += 1 + else: + print(f" FAIL: nonexistent.invalid -> carry={carry}, ip={list(ip)}" + f" (expected carry=1)") + failed += 1 + + except RuntimeError as e: + print(f"\n Test aborted: {e}") + except Exception as e: + print(f"\n Unexpected error: {e}") + import traceback + traceback.print_exc() + failed += 1 + finally: + # ---- Teardown -------------------------------------------------------- + print("\n=== Teardown ===") + + if mgr is not None: + try: + if inst is not None: + mgr.release(inst) + mgr.shutdown() + print(" VICE released") + except Exception as e: + print(f" VICE cleanup error: {e}") + + if dnsmasq_proc is not None: + try: + dnsmasq_proc.terminate() + try: + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stopped (exit={dnsmasq_proc.returncode})") + if failed > 0: + print(f" dnsmasq stderr:\n{stderr.decode()}") + except subprocess.TimeoutExpired: + dnsmasq_proc.kill() + dnsmasq_proc.wait() + print(" dnsmasq killed (did not terminate cleanly)") + except Exception as e: + print(f" dnsmasq cleanup error: {e}") + + # ---- Summary ------------------------------------------------------------- + total = passed + failed + print(f"\n{'='*60}") + print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + print(f"{'='*60}") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/test_http_integration.py b/tools/test_http_integration.py new file mode 100644 index 0000000..904e3a6 --- /dev/null +++ b/tools/test_http_integration.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""test_http_integration.py -- End-to-end HTTP integration test for c64-https. + +Exercises the C64's http_get_plain routine over real networking via the TAP +interface. The network architecture is: + + VICE (C64, 10.0.65.2) <--tap-c64 L2--> Host (10.0.65.1) + |-- dnsmasq (DHCP + DNS) + |-- HTTP server :80 + +Prerequisites: + - tap-c64 interface exists and is configured (10.0.65.1) + - x64sc (VICE) is on PATH + - dnsmasq is on PATH + +Usage: + python3 tools/test_http_integration.py +""" + +import os +import shutil +import subprocess +import sys +import time + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +# --------------------------------------------------------------------------- +# Skip checks +# --------------------------------------------------------------------------- + +def check_prerequisites(): + """Return True if all prerequisites are met, else print skip and return False.""" + if not os.path.exists("/sys/class/net/tap-c64"): + print("SKIP: tap-c64 interface not found") + return False + if shutil.which("x64sc") is None: + print("SKIP: x64sc not on PATH") + return False + if shutil.which("dnsmasq") is None: + print("SKIP: dnsmasq not on PATH") + return False + if shutil.which("sudo") is None: + print("SKIP: sudo not on PATH") + return False + return True + + +# --------------------------------------------------------------------------- +# dnsmasq helper +# --------------------------------------------------------------------------- + +def start_dnsmasq(): + """Start dnsmasq providing DHCP and DNS on tap-c64. Returns Popen.""" + cmd = [ + "sudo", "dnsmasq", + "--no-daemon", + "--interface=tap-c64", + "--bind-interfaces", + "--listen-address=10.0.65.1", + "--dhcp-range=10.0.65.2,10.0.65.10,255.255.255.0,5m", + "--address=/c64test.local/10.0.65.1", + "--dhcp-option=6,10.0.65.1", + "--log-queries", + "--no-resolv", + ] + print(f" dnsmasq cmd: {' '.join(cmd)}") + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # Give it a moment to bind + time.sleep(0.5) + if proc.poll() is not None: + _, stderr = proc.communicate() + raise RuntimeError(f"dnsmasq failed to start: {stderr.decode()}") + print(f" dnsmasq PID={proc.pid}") + return proc + + +# --------------------------------------------------------------------------- +# Main test +# --------------------------------------------------------------------------- + +def main(): + os.chdir(PROJECT_ROOT) + + if not check_prerequisites(): + sys.exit(0) + + # Late imports -- only needed if prerequisites are met + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from test_server import TestHTTPServer + from c64_test_harness import ( + Labels, ViceConfig, ViceInstanceManager, + read_bytes, write_bytes, jsr, wait_for_text, + ) + + passed = 0 + failed = 0 + dnsmasq_proc = None + server = None + mgr = None + inst = None + + try: + # ---- 1. Build -------------------------------------------------------- + print("\n=== Building ===") + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f" Build failed:\n{result.stderr}") + sys.exit(1) + print(" Build OK") + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded, {len(labels)} symbols") + + # Verify key labels exist + required_labels = [ + "http_get_plain", "http_host_ptr", "http_host_len", + "http_path_ptr", "http_path_len", "http_port", + "http_parse_state", "http_line_idx", "http_hdr_match", + "http_resp_len", "http_resp_buf", "http_status", + ] + for name in required_labels: + if labels.address(name) is None: + print(f" FATAL: required label '{name}' not found") + sys.exit(1) + + # ---- 2. Start dnsmasq ------------------------------------------------ + print("\n=== Starting dnsmasq ===") + dnsmasq_proc = start_dnsmasq() + + # ---- 3. Start HTTP test server --------------------------------------- + print("\n=== Starting HTTP test server ===") + server = TestHTTPServer(host="10.0.65.1", port=8080) + server.start() + print(" HTTP server listening on 10.0.65.1:8080") + + # ---- 4. Launch VICE -------------------------------------------------- + print("\n=== Starting VICE ===") + config = ViceConfig( + prg_path=PRG_PATH, + warp=False, # warp causes timing issues with ethernet + ntsc=True, + sound=False, + ethernet=True, + ethernet_mode="rrnet", + ethernet_driver="tuntap", + ethernet_interface="tap-c64", + ) + + mgr = ViceInstanceManager(config=config) + inst = mgr.acquire() + transport = inst.transport + print(f" VICE PID={inst.pid}, port={inst.port}") + + # ---- 5. Wait for boot menu ------------------------------------------ + print("\n=== Waiting for boot menu ===") + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print(" FATAL: Program menu did not appear") + failed += 1 + raise RuntimeError("Boot menu timeout") + print(" Boot menu appeared") + + # ---- 6. Network init (DHCP) ----------------------------------------- + print("\n=== Network init (pressing I for init) ===") + transport.resume() # CPU paused after wait_for_text screen read + transport.inject_keys([0x49]) # 'I' + + grid = wait_for_text(transport, "DHCP OK", timeout=60.0, verbose=False) + if grid is None: + print(" FAIL: DHCP did not complete within 60 seconds") + # Dump dnsmasq stderr for debugging + if dnsmasq_proc: + dnsmasq_proc.terminate() + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stderr:\n{stderr.decode()}") + dnsmasq_proc = None + failed += 1 + raise RuntimeError("DHCP timeout") + print(" DHCP OK") + passed += 1 + + # ---- 7. Set up HTTP parameters in C64 memory ------------------------- + print("\n=== Setting up HTTP parameters ===") + + # Write hostname to scratch RAM at $C000 + hostname = b"c64test.local\x00" + write_bytes(transport, 0xC000, hostname) + write_bytes(transport, labels.address("http_host_ptr"), [0x00, 0xC0]) + write_bytes(transport, labels.address("http_host_len"), [13]) + + # Write path to $C080 + path = b"/\x00" + write_bytes(transport, 0xC080, path) + write_bytes(transport, labels.address("http_path_ptr"), [0x80, 0xC0]) + write_bytes(transport, labels.address("http_path_len"), [1]) + + # Set port to 8080 (little-endian 16-bit: 0x1F90) + write_bytes(transport, labels.address("http_port"), [0x90, 0x1F]) + + # Initialize parser state + write_bytes(transport, labels.address("http_parse_state"), [0]) + write_bytes(transport, labels.address("http_line_idx"), [0]) + write_bytes(transport, labels.address("http_hdr_match"), [0]) + write_bytes(transport, labels.address("http_resp_len"), [0, 0]) + + print(" Parameters written to C64 memory") + + # ---- 8. Call http_get_plain ------------------------------------------ + print("\n=== Calling http_get_plain ===") + http_get_plain = labels.address("http_get_plain") + print(f" http_get_plain @ ${http_get_plain:04X}") + + try: + jsr(transport, http_get_plain, timeout=60.0) + print(" http_get_plain returned") + except TimeoutError: + print(" FAIL: http_get_plain timed out after 60 seconds") + failed += 1 + raise RuntimeError("http_get_plain timeout") + + # ---- 9. Read results ------------------------------------------------- + print("\n=== Checking results ===") + + # Check http_status (2 bytes, little-endian) + status_bytes = read_bytes(transport, labels.address("http_status"), 2) + status = status_bytes[0] | (status_bytes[1] << 8) + if status == 200: + print(f" PASS: http_status = {status}") + passed += 1 + else: + print(f" FAIL: http_status = {status}, expected 200 " + f"(bytes: ${status_bytes[0]:02X} ${status_bytes[1]:02X})") + failed += 1 + + # Check http_resp_len (2 bytes, little-endian) + resp_len_bytes = read_bytes(transport, labels.address("http_resp_len"), 2) + resp_len = resp_len_bytes[0] | (resp_len_bytes[1] << 8) + if resp_len == 9: + print(f" PASS: http_resp_len = {resp_len}") + passed += 1 + else: + print(f" FAIL: http_resp_len = {resp_len}, expected 9") + failed += 1 + + # Check response body + resp_body = read_bytes(transport, labels.address("http_resp_buf"), resp_len) + if resp_body == b"HELLO C64": + print(f" PASS: response body = 'HELLO C64'") + passed += 1 + else: + print(f" FAIL: response body = {resp_body!r}, expected b'HELLO C64'") + failed += 1 + + # ---- 10. Verify server received a well-formed request ---------------- + print("\n=== Checking server-side request log ===") + if len(server.requests) >= 1: + req = server.requests[0] + if req["method"] == "GET" and req["path"] == "/": + print(f" PASS: server received GET / " + f"(Host: {req['headers'].get('Host', '')})") + passed += 1 + else: + print(f" FAIL: server received {req['method']} {req['path']}, " + f"expected GET /") + failed += 1 + else: + print(f" FAIL: server received 0 requests, expected >= 1") + failed += 1 + + except RuntimeError as e: + print(f"\n Test aborted: {e}") + except Exception as e: + print(f"\n Unexpected error: {e}") + import traceback + traceback.print_exc() + failed += 1 + finally: + # ---- Teardown -------------------------------------------------------- + print("\n=== Teardown ===") + + if mgr is not None: + try: + if inst is not None: + mgr.release(inst) + mgr.shutdown() + print(" VICE released") + except Exception as e: + print(f" VICE cleanup error: {e}") + + if server is not None: + try: + server.stop() + print(" HTTP server stopped") + except Exception as e: + print(f" HTTP server cleanup error: {e}") + + if dnsmasq_proc is not None: + try: + dnsmasq_proc.terminate() + try: + _, stderr = dnsmasq_proc.communicate(timeout=5) + print(f" dnsmasq stopped (exit={dnsmasq_proc.returncode})") + if failed > 0: + print(f" dnsmasq stderr:\n{stderr.decode()}") + except subprocess.TimeoutExpired: + dnsmasq_proc.kill() + dnsmasq_proc.wait() + print(" dnsmasq killed (did not terminate cleanly)") + except Exception as e: + print(f" dnsmasq cleanup error: {e}") + + # ---- Summary ------------------------------------------------------------- + total = passed + failed + print(f"\n{'='*60}") + print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + print(f"{'='*60}") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/test_server.py b/tools/test_server.py new file mode 100644 index 0000000..b6675d6 --- /dev/null +++ b/tools/test_server.py @@ -0,0 +1,110 @@ +"""Reusable HTTP test server for C64 HTTPS integration testing.""" + +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler + +DEFAULT_HOST = "10.0.65.1" +DEFAULT_PORT = 80 + + +class _ReusableHTTPServer(HTTPServer): + """HTTPServer subclass that sets SO_REUSEADDR before bind.""" + + allow_reuse_address = True + +RESPONSE_BODY = "HELLO C64" + + +class _RequestHandler(BaseHTTPRequestHandler): + """Handles HTTP requests, recording them for test assertions.""" + + def do_GET(self): + if self.path == "/": + body = RESPONSE_BODY.encode("ascii") + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + else: + body = b"Not Found" + self.send_response(404) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + self.server.record_request(self.command, self.path, dict(self.headers)) + + def log_message(self, format, *args): + """Suppress default stderr logging during tests.""" + pass + + +class TestHTTPServer: + """HTTP server that runs in a background daemon thread. + + Attributes: + requests: list of dicts recording each received request + (keys: method, path, headers). + """ + + def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, ssl_context=None): + self.host = host + self.port = port + self.ssl_context = ssl_context + self.requests = [] + self._lock = threading.Lock() + + self._httpd = _ReusableHTTPServer((host, port), _RequestHandler) + + if ssl_context is not None: + self._httpd.socket = ssl_context.wrap_socket( + self._httpd.socket, server_side=True + ) + + # Give the handler a way to record requests back to us. + self._httpd.record_request = self._record_request + + self._thread = None + + # ---- public API -------------------------------------------------------- + + def start(self): + """Start serving in a daemon thread.""" + self._thread = threading.Thread(target=self._httpd.serve_forever) + self._thread.daemon = True + self._thread.start() + + def stop(self): + """Shut down the server and wait for the thread to exit.""" + self._httpd.shutdown() + if self._thread is not None: + self._thread.join() + + # ---- internals --------------------------------------------------------- + + def _record_request(self, method, path, headers): + with self._lock: + self.requests.append( + {"method": method, "path": path, "headers": headers} + ) + + +def start_test_server(host=DEFAULT_HOST, port=DEFAULT_PORT, ssl_context=None): + """Create, start, and return a TestHTTPServer instance.""" + server = TestHTTPServer(host=host, port=port, ssl_context=ssl_context) + server.start() + return server + + +if __name__ == "__main__": + srv = start_test_server() + print(f"Test server listening on {srv.host}:{srv.port}") + try: + srv._thread.join() + except KeyboardInterrupt: + print("\nShutting down.") + srv.stop()