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())