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>
266 lines
9.5 KiB
Python
266 lines
9.5 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]
|
|
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())
|