r""" web/bot_runner.py — start, watch and stop the bot as a child process. A fake child script stands in for run.py: it takes the run record the way the bot does, writes the env it was given, and waits. python tests\test_bot_runner.py """ import os import sys import json import time import pathlib import tempfile import threading import contextlib import io ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "src")) from ecw_bot import settings as cfg # noqa: E402 from ecw_bot.core import run_lock # noqa: E402 from web import bot_runner as br # noqa: E402 CASES = [] def case(fn): CASES.append(fn) return fn # The child: like run.py, it takes the record (cfg.LOGS from argv), dumps # the env keys under test, then sleeps until killed or `seconds` pass. CHILD = r''' import os, sys, json, time, pathlib sys.path.insert(0, sys.argv[1]) from ecw_bot import settings as cfg from ecw_bot.core import run_lock cfg.LOGS = pathlib.Path(sys.argv[2]) log = cfg.LOGS / "run_child.log" log.write_text("[LOGIN] fake\n", encoding="utf-8") run_lock.acquire(log) keys = ["SAVE_CORRECTIONS", "ECW_KEEP_OPEN", "QUIET_BOXES", "CONFIRM_BEFORE_LOOKUP", "ECW_RUN_SOURCE", "ECW_STARTED_BY", "ECWBOT_TEST_STALE", "ECWBOT_TEST_REAL", "RESULTS_DB_CONN"] (cfg.LOGS / "env.json").write_text( json.dumps({k: os.environ.get(k) for k in keys}), encoding="utf-8") time.sleep(float(sys.argv[3])) run_lock.finish(0) ''' def _runner(tmp, seconds=30): script = pathlib.Path(tmp) / "child.py" script.write_text(CHILD, encoding="utf-8") cmd = [sys.executable, str(script), str(ROOT / "src"), str(cfg.LOGS), str(seconds)] return br.BotRunner(command=cmd, root=ROOT) def _cleanup(r): """Stop whatever a case left running.""" try: r.stop() except (br.NotRunning, br.StopFailed): pass def _wait(pred, timeout=15): end = time.monotonic() + timeout while time.monotonic() < end: if pred(): return True time.sleep(0.2) return False @case def start_runs_the_child_with_the_forced_env(tmp): r = _runner(tmp) # The parent says LIVE-WRITE in its REAL environment (not from .env, so # the .env-key filter does not drop it): FORCED_ENV must override it. file_keys = getattr(cfg, "ENV_FILE_KEYS", set()) was_file_key = "SAVE_CORRECTIONS" in file_keys was_value = os.environ.get("SAVE_CORRECTIONS") file_keys.discard("SAVE_CORRECTIONS") os.environ["SAVE_CORRECTIONS"] = "1" try: st = r.start(started_by="asha") assert st["running"] is True, st assert _wait(lambda: (cfg.LOGS / "env.json").exists()) env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8")) forced = {k: env[k] for k in br.FORCED_ENV} assert forced == {"SAVE_CORRECTIONS": "0", "ECW_KEEP_OPEN": "0", "QUIET_BOXES": "1", "CONFIRM_BEFORE_LOOKUP": "0", "ECW_RUN_SOURCE": "web"}, env assert env["ECW_STARTED_BY"] == "asha", env assert _wait(lambda: r.status()["starting"] is False) st = r.status() assert st["running"] is True and st["source"] == "web", st assert st["log_path"].endswith("run_child.log"), st assert st["started_by"] == "asha", "the card says who started it" finally: if was_value is None: os.environ.pop("SAVE_CORRECTIONS", None) else: os.environ["SAVE_CORRECTIONS"] = was_value if was_file_key: file_keys.add("SAVE_CORRECTIONS") _cleanup(r) @case def the_run_writes_to_the_database_the_page_reads(tmp): """Review 2026-10-14: the child reads .env afresh, but the page keeps the results database it was started with. An RESULTS_DB_CONN edited since would send the run's rows where the page never looks — so the child is given the page's own connection.""" r = _runner(tmp) was = cfg.RESULTS_DB_CONN cfg.RESULTS_DB_CONN = "DSN=the-database-this-page-reads" try: r.start() assert _wait(lambda: (cfg.LOGS / "env.json").exists()) env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8")) assert env["RESULTS_DB_CONN"] == "DSN=the-database-this-page-reads", \ "the child does not use the page's results database" finally: cfg.RESULTS_DB_CONN = was _cleanup(r) @case def the_child_reads_dotenv_afresh_not_the_servers_copy(tmp): """Review 2026-10-14: the web server took .env into its own environment when it started, and the child inherited that copy — settings.py's setdefault then kept it, so an .env edited since (claim sheet, claim limit) was not seen by a page run. A key that came from .env is left out of the child's environment; a real environment key is kept.""" r = _runner(tmp) file_keys = getattr(cfg, "ENV_FILE_KEYS", set()) os.environ["ECWBOT_TEST_STALE"] = "old value from .env" file_keys.add("ECWBOT_TEST_STALE") os.environ["ECWBOT_TEST_REAL"] = "set in the real environment" try: r.start() assert _wait(lambda: (cfg.LOGS / "env.json").exists()) env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8")) assert env["ECWBOT_TEST_STALE"] is None, env assert env["ECWBOT_TEST_REAL"] == "set in the real environment", env finally: os.environ.pop("ECWBOT_TEST_STALE", None) os.environ.pop("ECWBOT_TEST_REAL", None) file_keys.discard("ECWBOT_TEST_STALE") _cleanup(r) @case def a_second_start_is_refused(tmp): r = _runner(tmp) other_runner = _runner(tmp) try: r.start() assert _wait(lambda: run_lock.is_live(run_lock.read())) for other in (r, other_runner): try: other.start() except br.AlreadyRunning as exc: assert "already going" in str(exc), exc else: raise AssertionError("second start allowed") finally: _cleanup(r) @case def stop_kills_the_run_and_marks_it_stopped(tmp): r = _runner(tmp) try: r.start() assert _wait(lambda: run_lock.is_live(run_lock.read())) pid = run_lock.read()["pid"] st = r.stop(stopped_by="olu") assert st["running"] is False, st assert st["stopped_by_operator"] is True, st assert run_lock.pid_alive(pid) is False # The killed bot cannot end its own events file: the page does. events = cfg.LOGS / "events_child.jsonl" last = json.loads(events.read_text(encoding="utf-8").splitlines()[-1]) assert last["kind"] == "stopped" and last["stopped_by"] == "olu", last finally: _cleanup(r) @case def stop_while_still_starting_is_the_operator_not_a_crash(tmp): """No record yet (the child is still importing): the runner stopped it, so it must not read as a crash with exit code 1.""" r = _runner(tmp) try: st = r.start() assert st["starting"] is True, st st = r.stop() assert st["running"] is False and st["starting"] is False, st assert st["stopped_by_operator"] is True, st assert st["exit_code"] is None, st finally: _cleanup(r) @case def a_stop_that_does_not_kill_raises_and_leaves_the_record(tmp): """taskkill refused (an elevated run): the record must not be marked ended while the bot is still going.""" class NoKill(br.BotRunner): stop_wait = 1.0 def _kill(self, pid): pass script = pathlib.Path(tmp) / "child.py" script.write_text(CHILD, encoding="utf-8") r = NoKill(command=[sys.executable, str(script), str(ROOT / "src"), str(cfg.LOGS), "30"], root=ROOT) try: r.start() assert _wait(lambda: run_lock.is_live(run_lock.read())) pid = run_lock.read()["pid"] try: r.stop() except br.StopFailed as exc: assert str(pid) in str(exc) and "still running" in str(exc), exc else: raise AssertionError("stop did not raise StopFailed") rec = run_lock.read() assert not rec["ended_at"] and not rec["stopped_by_operator"], rec assert run_lock.is_live(rec) is True assert r.status()["running"] is True finally: br.BotRunner._kill(r, r._proc.pid) _wait(lambda: not run_lock.pid_alive(r._proc.pid)) @case def two_starts_at_once_spawn_one_child(tmp): r = _runner(tmp) results = [] gate = threading.Barrier(2) def go(): gate.wait() try: r.start() results.append("started") except br.AlreadyRunning: results.append("refused") threads = [threading.Thread(target=go) for _ in range(2)] try: for t in threads: t.start() for t in threads: t.join(30) assert sorted(results) == ["refused", "started"], results finally: _cleanup(r) @case def stop_never_kills_its_own_process(tmp): """A live record whose pid is this process (the web server itself).""" run_lock.acquire("self.log", source="web") try: _runner(tmp).stop() except br.NotRunning: pass else: raise AssertionError("stop went for its own pid") assert run_lock.pid_alive(os.getpid()) is True run_lock.finish(0) @case def stop_with_nothing_running_says_so(tmp): try: _runner(tmp).stop() except br.NotRunning: pass else: raise AssertionError("stop of nothing did not raise") @case def a_run_that_ends_reports_its_exit_code(tmp): r = _runner(tmp, seconds=0.5) try: r.start() assert _wait(lambda: r.status()["running"] is False) st = r.status() assert st["exit_code"] == 0 and st["stopped_by_operator"] is False, st finally: _cleanup(r) @case def a_terminal_run_is_seen_and_blocks_start(tmp): """The record of a live run this runner did not start (this test process stands in for a terminal run).""" run_lock.acquire("terminal.log", source="terminal") r = _runner(tmp) st = r.status() assert st["running"] is True and st["source"] == "terminal", st try: r.start() except br.AlreadyRunning: pass else: raise AssertionError("started over a terminal run") run_lock.finish(0) @case def a_terminal_run_after_a_page_run_shows_as_itself(tmp): """The page's child ended; a later terminal run (this test process stands in for it) ended with exit 4. The page must show that run, not the old child's exit 0.""" r = _runner(tmp, seconds=0.5) try: r.start() assert _wait(lambda: r.status()["running"] is False) assert _wait(lambda: r._proc.poll() is not None) assert r.status()["exit_code"] == 0 run_lock.acquire("t.log", source="terminal") run_lock.finish(4) st = r.status() assert st["source"] == "terminal", st assert st["exit_code"] == 4 and st["running"] is False, st assert st["log_path"] == "t.log", st finally: _cleanup(r) class _NoKillRecorder(br.BotRunner): """Records what stop() would kill; never kills anything.""" stop_wait = 1.0 def __init__(self, *a, **k): super().__init__(*a, **k) self.killed = [] def _kill(self, pid): self.killed.append(pid) @case def stop_refuses_a_run_started_in_the_terminal(tmp): """A live terminal run (maybe LIVE-WRITE): the page cannot stop it.""" import subprocess script = pathlib.Path(tmp) / "child.py" script.write_text(CHILD, encoding="utf-8") env = {k: v for k, v in os.environ.items() if k != "ECW_RUN_SOURCE"} p = subprocess.Popen([sys.executable, str(script), str(ROOT / "src"), str(cfg.LOGS), "30"], env=env) r = _NoKillRecorder(command=["unused"], root=ROOT) try: assert _wait(lambda: run_lock.is_live(run_lock.read())) assert run_lock.read()["source"] == "terminal" try: r.stop() except br.NotRunning as exc: assert "started in the terminal" in str(exc), exc else: raise AssertionError("stop went for a terminal run") assert r.killed == [], r.killed assert run_lock.pid_alive(p.pid) is True assert run_lock.is_live(run_lock.read()) is True finally: p.kill() p.wait() @case def stop_refuses_a_terminal_record_of_this_process(tmp): """The record's pid is the test process itself: refused with the terminal message, and the test process is not killed.""" run_lock.acquire("t.log", source="terminal") r = _NoKillRecorder(command=["unused"], root=ROOT) try: r.stop() except br.NotRunning as exc: assert "started in the terminal" in str(exc), exc else: raise AssertionError("stop did not refuse") assert r.killed == [] and run_lock.pid_alive(os.getpid()) is True run_lock.finish(0) def main() -> int: failures = 0 orig = cfg.LOGS for fn in CASES: name = fn.__name__.replace("_", " ") with tempfile.TemporaryDirectory() as tmp: cfg.LOGS = pathlib.Path(tmp) / "Logs" cfg.LOGS.mkdir() try: with contextlib.redirect_stdout(io.StringIO()): fn(tmp) # the runner's [WEB] prints stay out print(f" ok {name}") except AssertionError as exc: print(f" FAIL {name}: {exc}") failures += 1 except Exception as exc: print(f" ERROR {name}: {type(exc).__name__}: {exc}") failures += 1 finally: cfg.LOGS = orig print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())