r""" The claim watchdog in the flow — `flows/export_medcor_ready.py` (2026-10-22). No browser, no eCW: login, run record, disk and the claims phase are stand-ins. python tests\test_watchdog_flow.py * `_process_claims` runs the claim loop under `watchdog.watching()`. * A fired watchdog stops the loop at the top of the next claim — before its search — so the rest of the queue is Not Reached, not a row of failed searches. * `main()` turns the stall into **exit 8** with a Run Error at step `stalled`, whether it arrives as `Stalled` or as the error the frozen call came back with once the driver was closed. Claim numbers here are made up. """ import io import sys import json import types import pathlib import tempfile import contextlib ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "src")) from ecw_bot import settings as cfg # noqa: E402 from ecw_bot.core import run_events, watchdog # noqa: E402 from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 CASES = [] def case(fn): CASES.append(fn) return fn class Patch: def __init__(self, *triples): self.triples = triples def __enter__(self): self.saved = [(o, n, getattr(o, n)) for o, n, _ in self.triples] for o, n, v in self.triples: setattr(o, n, v) def __exit__(self, *e): for o, n, v in self.saved: setattr(o, n, v) def _fire(msg="No progress for 10 min at claim 2/3 9002: read the form"): watchdog._state.update(fired=True, message=msg, answered=False) def _unfire(): watchdog._state.update(fired=False, message="", answered=False) @case def the_claim_loop_runs_under_the_watchdog(): seen = {} @contextlib.contextmanager def watching(limit): seen["limit"] = limit yield with Patch((watchdog, "watching", watching), (flow, "_check_claims", lambda p, i: seen.setdefault("ran", True))): flow._process_claims(None, {"ids": ["9001"]}) assert seen == {"limit": watchdog.limit_seconds(), "ran": True}, seen @case def a_fired_watchdog_stops_the_loop_before_the_next_search(): searched = [] stubs = ( (flow.preflight, "reset", lambda: None), (flow.preflight, "check_search", lambda page: None), (flow.claim_form, "clear_screen", lambda *a: None), (flow.claim_results, "search_claim", lambda page, cid: searched.append(cid) or {"found": False}), (cfg, "RESULTS_DB", False), ) _fire() try: with Patch(*stubs), contextlib.redirect_stdout(io.StringIO()): try: flow._check_claims(types.SimpleNamespace(context=None), {"ids": ["9001", "9002"]}) raise AssertionError("did not stop") except watchdog.Stalled as exc: assert "9002: read the form" in str(exc), exc finally: _unfire() assert searched == [], searched def _main_with(claims_phase, *extra): run_events._reset() tmp = pathlib.Path(tempfile.mkdtemp()) log = tmp / "run_20261022_190000.log" browser = types.SimpleNamespace(close=lambda: None) page = types.SimpleNamespace(url="fake://landing") with Patch((flow.runlog, "start", lambda: log), (flow.run_lock, "acquire", lambda p, source=None: {}), (flow.run_lock, "finish", lambda c: None), (cfg, "validate", lambda: []), (flow, "start_mode_gate", lambda: True), (flow, "_disk_check", lambda: True), (cfg, "BROWSER_CDP_URL", ""), (flow, "sync_playwright", lambda: contextlib.nullcontext(None)), (flow, "_login_with_retry", lambda pw: (browser, None, page)), (flow.guard, "record_success", lambda: None), (flow, "KEEP_OPEN", False), (flow, "_claims_phase", claims_phase), # No real box on the desk (the "Chrome open" one included). (flow.msgbox, "info_box", lambda *a, **k: None), *extra): with contextlib.redirect_stdout(io.StringIO()): code = flow.main() lines = run_events.path_for(log).read_text(encoding="utf-8").splitlines() return code, [json.loads(l) for l in lines] @case def stalled_is_exit_8_with_a_run_error(): def phase(page): raise watchdog.Stalled("No progress for 10 min at claim 1/1 9001") code, ev = _main_with(phase) assert code == 8, code err = [e for e in ev if e["kind"] == "run_error"] assert err and err[0]["step"] == "stalled", ev assert "claim 1/1 9001" in err[0]["message"], err assert ev[-1]["kind"] == "run_end" and ev[-1]["exit_code"] == 8 @case def the_frozen_calls_own_error_after_a_stall_is_exit_8_too(): def phase(page): _fire("No progress for 10 min at clear the page after the last claim") raise RuntimeError("Target page, context or browser has been closed") try: code, ev = _main_with(phase) finally: _unfire() assert code == 8, code err = [e for e in ev if e["kind"] == "run_error"][0] assert err["step"] == "stalled" and "clear the page" in err["message"] assert "Target page" in err["traceback"], "the call's error kept" @case def any_other_claims_error_is_still_exit_5(): def phase(page): raise RuntimeError("menu broke") code, ev = _main_with(phase) assert code == 5, code assert [e for e in ev if e["kind"] == "run_error"][0]["step"] == "claims" @case def no_chrome_on_the_port_is_opened_by_the_run(): """Start on the Portal is all it takes (operator, 2026-10-22): with BROWSER_CDP_URL set and no Chrome there, the run opens it, then logs in; if it cannot, exit 2 before any login attempt.""" opened = [] def start(url): opened.append(url) return True code, ev = _main_with(lambda page: None, (cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"), (flow, "cdp_ready", lambda url: False), (flow, "start_chrome", start), (flow, "chrome_proxy_problem", lambda url: None)) assert opened == ["http://127.0.0.1:9222"] and code == 0, (opened, code) # A Chrome on the port without the eCW proxy: exit 2, no login. logged_in = [] code, ev = _main_with(lambda page: None, (cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"), (flow, "cdp_ready", lambda url: True), (flow, "chrome_proxy_problem", lambda url: "started without the eCW proxy"), (flow, "_login_with_retry", lambda pw: logged_in.append(1))) assert code == 2 and not logged_in, (code, logged_in) logged_in = [] code, ev = _main_with(lambda page: None, (cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"), (flow, "cdp_ready", lambda url: False), (flow, "start_chrome", lambda url: False), (flow, "_login_with_retry", lambda pw: logged_in.append(1))) assert code == 2 and not logged_in, (code, logged_in) err = [e for e in ev if e["kind"] == "run_error"][0] assert err["step"] == "config" and "Could not open Chrome" in err["message"], err @case def a_box_says_chrome_is_open_before_the_login(): """Operator, 2026-10-23: "just chrome open nu sonna pothum" — the run that opened Chrome says so and waits, so the Claude extension can be added before eCW. A Chrome already on the port: no box.""" seen = [] def login(pw): seen.append("login") return (types.SimpleNamespace(close=lambda: None), None, types.SimpleNamespace(url="fake://landing")) box = (flow.msgbox, "info_box", lambda title, msg, needs_person=False: seen.append( ("box", title, needs_person))) code, _ = _main_with(lambda page: None, box, (cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"), (flow, "cdp_ready", lambda url: False), (flow, "start_chrome", lambda url: True), (flow, "chrome_proxy_problem", lambda url: None), (flow, "_login_with_retry", login)) assert code == 0, code assert seen[-1] == "login" and len(seen) == 2, seen _, title, needs_person = seen[0] assert "Chrome" in title and needs_person is True, seen seen.clear() code, _ = _main_with(lambda page: None, box, (cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"), (flow, "cdp_ready", lambda url: True), (flow, "chrome_proxy_problem", lambda url: None), (flow, "_login_with_retry", login)) assert seen == ["login"], seen 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 run_events._reset() print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())