Files
2026-10-30 10:58:35 +05:30

268 lines
9.6 KiB
Python

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]
# Operator, 2026-10-29: bulk runs show no box at all, so this one is
# quieted by QUIET_BOXES like any other (a log line instead).
assert "Chrome" in title and needs_person is False, 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())