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>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
r"""
|
|
web/run_status.py — the bot's run log read into the page's live status.
|
|
|
|
python tests\test_run_status.py
|
|
"""
|
|
|
|
import sys
|
|
import pathlib
|
|
import tempfile
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
from web import run_status as rs # noqa: E402
|
|
|
|
CASES = []
|
|
|
|
|
|
def case(fn):
|
|
CASES.append(fn)
|
|
return fn
|
|
|
|
|
|
def _status(**over):
|
|
"""The runner's status (web/bot_runner.py): no run yet, unless `over`
|
|
says otherwise."""
|
|
st = {"running": False, "starting": False, "log_path": None,
|
|
"out_path": None, "exit_code": None, "stopped_by_operator": False,
|
|
"started_at": None, "ended_at": None}
|
|
st.update(over)
|
|
return st
|
|
|
|
|
|
LOG = """\
|
|
======================================================================
|
|
2026-10-14 10:00:00 — run start
|
|
[CONFIG] user=x otp=imap
|
|
[LOGIN] Opening the login page
|
|
[OTP] Waiting for the approval mail
|
|
[OK] Logged in in 95s
|
|
[CLAIMS] --- claim 1/3: 9000001 ---
|
|
[RESULT] claim 1/3 9000001: Pass
|
|
[CLAIMS] --- claim 2/3: 9000002 ---
|
|
[CLAIMS] --- claim 2/3 done, moving to next ---
|
|
[RESULT] claim 2/3 9000002: Fail
|
|
[CLAIMS] --- claim 3/3: 9000003 ---
|
|
"""
|
|
|
|
|
|
@case
|
|
def phase_follows_the_last_tagged_line():
|
|
assert rs.parse("[LOGIN] x")["phase"] == "Logging in"
|
|
assert rs.parse("[LOGIN] x\n[2FA] y")["phase"] == \
|
|
"Waiting for the 2FA approval mail"
|
|
assert rs.parse("[OTP] y\n[OK] Logged in in 3s")["phase"] == "Logged in"
|
|
assert rs.parse(LOG)["phase"] == "Checking claims"
|
|
assert rs.parse("")["phase"] == "Starting"
|
|
|
|
|
|
@case
|
|
def progress_and_current_claim_come_from_the_claim_header():
|
|
p = rs.parse(LOG)
|
|
assert p["progress"] == [3, 3] and p["current"] == "9000003", p
|
|
|
|
|
|
@case
|
|
def a_done_line_is_not_a_claim_header():
|
|
p = rs.parse("[CLAIMS] --- claim 2/3 done, moving to next ---")
|
|
assert p["progress"] is None, p
|
|
|
|
|
|
@case
|
|
def counts_come_from_result_lines():
|
|
c = rs.parse(LOG + "[RESULT] claim 3/3 9000003: Review\n")["counts"]
|
|
assert c == {"Pass": 1, "Fail": 1, "Review": 1, "Not found": 0}, c
|
|
|
|
|
|
@case
|
|
def tail_is_the_last_lines():
|
|
text = "\n".join(f"line {i}" for i in range(100))
|
|
tail = rs.parse(text)["tail"]
|
|
assert len(tail) == rs.TAIL_LINES and tail[-1] == "line 99", tail
|
|
|
|
|
|
@case
|
|
def guard_line_is_kept():
|
|
p = rs.parse("[GUARD] Refused: next attempt allowed at 10:40")
|
|
assert p["guard"] == "[GUARD] Refused: next attempt allowed at 10:40"
|
|
|
|
|
|
@case
|
|
def exit_codes_read_as_plain_words():
|
|
assert rs.exit_text(0) == "Finished"
|
|
assert "setting" in rs.exit_text(2)
|
|
assert "guard" in rs.exit_text(3)
|
|
assert "claims step failed" in rs.exit_text(5)
|
|
assert "2FA" in rs.exit_text(6)
|
|
assert "restricted" in rs.exit_text(4) and "do not retry" in rs.exit_text(4)
|
|
assert rs.exit_text(1) == "The run failed (see the log)"
|
|
assert rs.exit_text(9) == "Ended with code 9"
|
|
assert rs.exit_text(None, stopped=True) == "Stopped by operator"
|
|
assert "no exit code" in rs.exit_text(None)
|
|
|
|
|
|
@case
|
|
def read_tail_reads_the_end_of_a_file_and_missing_is_empty():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = pathlib.Path(tmp) / "run.log"
|
|
p.write_text("a\n" * 1000 + "LAST\n", encoding="utf-8")
|
|
assert rs.read_tail(p, max_bytes=100).endswith("LAST\n")
|
|
assert rs.read_tail(pathlib.Path(tmp) / "none.log") == ""
|
|
assert rs.read_tail(None) == ""
|
|
|
|
|
|
@case
|
|
def summarize_running_uses_the_phase_as_the_message():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = pathlib.Path(tmp) / "run.log"
|
|
p.write_text(LOG, encoding="utf-8")
|
|
s = rs.summarize(_status(running=True, log_path=str(p),
|
|
started_at="2026-10-14T10:00:00", pid=123,
|
|
source="web"))
|
|
assert s["message"] == "Checking claims", s
|
|
assert s["progress"] == [3, 3], s
|
|
for k in ("log_path", "out_path", "pid"):
|
|
assert k not in s, (k, s)
|
|
assert set(s) == {"running", "starting", "started_at", "ended_at",
|
|
"exit_code", "stopped_by_operator", "source", "phase",
|
|
"progress", "current", "counts", "tail", "message",
|
|
"elapsed_s", "claims", "results_run_at",
|
|
"started_by"}, set(s)
|
|
|
|
|
|
@case
|
|
def summarize_ended_says_how_and_how_long():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = pathlib.Path(tmp) / "run.log"
|
|
p.write_text("[GUARD] Refused: next attempt allowed at 10:40\n",
|
|
encoding="utf-8")
|
|
s = rs.summarize(_status(log_path=str(p), exit_code=3,
|
|
started_at="2026-10-14T10:00:00",
|
|
ended_at="2026-10-14T10:04:12"))
|
|
assert s["message"].startswith("Login guard refused"), s
|
|
assert "next attempt allowed at 10:40" in s["message"], s
|
|
assert "[GUARD]" not in s["message"], s
|
|
assert s["elapsed_s"] == 252, s
|
|
|
|
|
|
QUEUE_LOG = """\
|
|
[CLAIMS] Queue: 9000001, 9000002, 9000003, 9000004
|
|
[CLAIMS] --- claim 1/4: 9000001 ---
|
|
[RESULT] claim 1/4 9000001: Pass
|
|
[CLAIMS] --- claim 2/4: 9000002 ---
|
|
[RESULT] claim 2/4 9000002: Fail
|
|
[CLAIMS] --- claim 3/4: 9000003 ---
|
|
"""
|
|
|
|
|
|
@case
|
|
def claims_lists_every_queued_claim_with_its_status():
|
|
"""The Claim status panel (operator, 2026-10-15): every claim of the
|
|
run — done with its result, the one being checked, the ones waiting."""
|
|
claims = rs.parse(QUEUE_LOG)["claims"]
|
|
assert claims == [
|
|
{"n": 1, "id": "9000001", "status": "Pass", "done": True},
|
|
{"n": 2, "id": "9000002", "status": "Fail", "done": True},
|
|
{"n": 3, "id": "9000003", "status": "Checking", "done": False},
|
|
{"n": 4, "id": "9000004", "status": "Waiting", "done": False},
|
|
], claims
|
|
|
|
|
|
@case
|
|
def claims_without_a_queue_line_come_from_the_claim_lines():
|
|
"""An older log, written before the Queue line existed."""
|
|
claims = rs.parse(LOG)["claims"]
|
|
assert [c["id"] for c in claims] == ["9000001", "9000002", "9000003"]
|
|
assert [c["status"] for c in claims] == ["Pass", "Fail", "Checking"]
|
|
|
|
|
|
@case
|
|
def a_claim_left_half_way_is_not_finished_once_the_run_ended():
|
|
def summary(running):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = pathlib.Path(tmp) / "run.log"
|
|
p.write_text(QUEUE_LOG, encoding="utf-8")
|
|
return rs.summarize(_status(
|
|
running=running, log_path=str(p),
|
|
stopped_by_operator=not running,
|
|
started_at="2026-10-15T10:00:00",
|
|
ended_at=None if running else "2026-10-15T10:05:00"))
|
|
live = summary(True)["claims"]
|
|
assert live[2]["status"] == "Checking" and live[3]["status"] == "Waiting"
|
|
ended = summary(False)["claims"]
|
|
assert ended[2]["status"] == "Not finished", ended
|
|
assert ended[3]["status"] == "Not checked", ended
|
|
assert [c["done"] for c in ended] == [True, True, False, False], ended
|
|
|
|
|
|
@case
|
|
def a_not_found_claim_is_done_with_no_result_category():
|
|
"""Not found ends the claim (it counts as done) — the panel shows it
|
|
done, but it has no results row to open."""
|
|
claims = rs.parse("[CLAIMS] Queue: 9000001, 9000002\n"
|
|
"[CLAIMS] --- claim 1/2: 9000001 ---\n"
|
|
"[RESULT] claim 1/2 9000001: Not found\n")["claims"]
|
|
assert claims[0] == {"n": 1, "id": "9000001", "status": "Not found",
|
|
"done": True}, claims
|
|
assert claims[1]["done"] is False, claims
|
|
|
|
|
|
@case
|
|
def the_results_run_at_line_names_the_run_the_claims_are_saved_in():
|
|
"""Review 2026-10-19: the Claim status panel opened a claim in the run
|
|
the table showed, not the one it was saved in. The bot names that run
|
|
right after the Queue line (only with RESULTS_DB=1)."""
|
|
text = ("[CLAIMS] Queue: 9000001, 9000002\n"
|
|
"[CLAIMS] Results run_at: 2026-10-16T19:08:37\n"
|
|
"[CLAIMS] --- claim 1/2: 9000001 ---\n")
|
|
assert rs.parse(text)["results_run_at"] == "2026-10-16T19:08:37"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = pathlib.Path(tmp) / "run.log"
|
|
p.write_text(text, encoding="utf-8")
|
|
s = rs.summarize(_status(running=True, log_path=str(p)))
|
|
assert s["results_run_at"] == "2026-10-16T19:08:37", s
|
|
|
|
|
|
@case
|
|
def no_results_run_at_line_is_none():
|
|
"""RESULTS_DB off (or an older log): no line, no saved run."""
|
|
assert rs.parse(QUEUE_LOG)["results_run_at"] is None
|
|
assert rs.parse("")["results_run_at"] is None
|
|
assert rs.summarize(_status())["results_run_at"] is None
|
|
|
|
|
|
@case
|
|
def a_later_run_in_the_same_log_drops_the_earlier_runs_time():
|
|
"""A new Queue line starts a new run: a run_at from before it is not
|
|
this run's."""
|
|
text = ("[CLAIMS] Queue: 9000001\n"
|
|
"[CLAIMS] Results run_at: 2026-10-16T19:08:37\n"
|
|
"[CLAIMS] Queue: 9000002\n")
|
|
assert rs.parse(text)["results_run_at"] is None
|
|
|
|
|
|
@case
|
|
def a_results_run_at_that_is_not_a_time_is_ignored():
|
|
"""It goes into the page's link: only the bot's own ISO form counts."""
|
|
for bad in ("[CLAIMS] Results run_at: 2026-10-16T19:08:37&x=1",
|
|
"[CLAIMS] Results run_at: soon",
|
|
"[CLAIMS] Results run_at: "):
|
|
assert rs.parse(bad)["results_run_at"] is None, bad
|
|
|
|
|
|
@case
|
|
def a_run_that_ended_with_no_end_time_has_no_duration():
|
|
"""Review 2026-10-14: a run killed outside the page (or crashed) never
|
|
wrote ended_at; measured to now(), its duration grew on every poll —
|
|
"Ended ... in 87m 3s". No end time, no duration. A running run still
|
|
counts up to now."""
|
|
ended = rs.summarize(_status(started_at="2026-10-14T10:00:00"))
|
|
assert ended["elapsed_s"] is None, ended
|
|
running = rs.summarize(_status(running=True,
|
|
started_at="2026-10-14T10:00:00"))
|
|
assert isinstance(running["elapsed_s"], int), running
|
|
|
|
|
|
@case
|
|
def summarize_starting_reads_the_child_output_file():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = pathlib.Path(tmp) / "web_run.out"
|
|
out.write_text("Traceback: boom\n", encoding="utf-8")
|
|
s = rs.summarize(_status(running=True, starting=True,
|
|
out_path=str(out)))
|
|
assert s["message"] == "Starting" and s["tail"] == ["Traceback: boom"], s
|
|
|
|
|
|
@case
|
|
def a_done_line_with_extra_words_is_not_a_claim_header():
|
|
p = rs.parse("[CLAIMS] --- claim 2/3 done (shown in its review box), "
|
|
"moving to next ---")
|
|
assert p["progress"] is None and p["current"] is None, p
|
|
|
|
|
|
@case
|
|
def a_crlf_log_parses_the_same():
|
|
a, b = rs.parse(LOG), rs.parse(LOG.replace("\n", "\r\n"))
|
|
for k in ("phase", "progress", "current", "counts", "guard"):
|
|
assert a[k] == b[k], (k, a[k], b[k])
|
|
assert b["tail"] == a["tail"], b["tail"]
|
|
|
|
|
|
@case
|
|
def summarize_counts_the_whole_log_not_just_its_tail():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
p = pathlib.Path(tmp) / "run.log"
|
|
body = ("[RESULT] claim 1/9 111: Pass\n"
|
|
"[RESULT] claim 2/9 222: Fail\n"
|
|
+ "[CLAIMS] noise line to push the results out of the tail\n"
|
|
* 10_000
|
|
+ "[CLAIMS] --- claim 9/9: 999 ---\n")
|
|
p.write_text(body, encoding="utf-8")
|
|
assert p.stat().st_size > 256_000
|
|
s = rs.summarize(_status(running=True, log_path=str(p)))
|
|
assert s["counts"]["Pass"] == 1 and s["counts"]["Fail"] == 1, s["counts"]
|
|
assert s["progress"] == [9, 9] and len(s["tail"]) == rs.TAIL_LINES, s
|
|
|
|
|
|
@case
|
|
def the_tail_hides_the_otp_code_and_the_username():
|
|
text = ("[CONFIG] user=jsmith otp=imap proxy=-:- headless=False\n"
|
|
"[OTP] Code 482913 in 'Your code 482913' (sent 10:01:02)\n"
|
|
"[OTP] Approval link in 'Sign-in' (sent 10:01:02)\n")
|
|
p = rs.parse(text)
|
|
assert p["tail"] == [
|
|
"[CONFIG] user=(hidden) otp=imap proxy=-:- headless=False",
|
|
"[OTP] Code (hidden)",
|
|
"[OTP] Approval link in 'Sign-in' (sent 10:01:02)"], p["tail"]
|
|
joined = "\n".join(p["tail"])
|
|
assert "482913" not in joined and "jsmith" not in joined, joined
|
|
assert p["phase"] == "Waiting for the 2FA approval mail", p
|
|
|
|
|
|
def main() -> int:
|
|
failures = 0
|
|
for fn in CASES:
|
|
name = fn.__name__.replace("_", " ")
|
|
try:
|
|
fn()
|
|
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
|
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|