Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427 lines
14 KiB
Python
427 lines
14 KiB
Python
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())
|