Files
ecw-charge-validation/src/ecw_bot/core/page_utils.py
T
ManikandanandClaude Opus 5.5 ba15a574ab Initial import: eCW Charge Validation bot and results Portal
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>
2026-10-23 18:38:17 +05:30

331 lines
13 KiB
Python

"""
Page helpers shared by every screen. No eCW knowledge lives here.
These earned their edge cases the hard way, which is exactly why they are
in one place instead of being reimplemented per module:
* `shot` writes into a **dated** screenshot folder and never raises — a
screenshot failing must not lose the run it was documenting.
A FAILURE shot (`is_failure_tag`) also saves the page's DOM beside
it as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE`, 2026-10-21):
when eCW changes a selector, the HTML to fix it from is already on
disk — no re-run, no hand copy from DevTools into Dom.txt. PHI, like
the screenshots: it stays in the gitignored Screenshots folder.
* `visible` is an INSTANT "is it on screen right now?" and `appears` is
"give it up to N ms to show up". They are two functions because
Playwright 1.62's `is_visible(timeout=...)` silently ignores its timeout
(review item P8, 2026-10-01): every caller that passed `timeout=10_000`
expecting a grace period was getting an instant check. Both look at
VISIBLE matches only rather than `.first` — eCW renders some ids twice
(`#jellybean-panelLink4` among them), and a hidden leftover copy first in
the DOM used to make `.first` answer False while the real one was on
screen.
* `goto_with_retry` retries `net::ERR_ABORTED` — the corporate proxy
intermittently kills the first navigation, and one try used to abort a whole
run.
* `reset_scroll` does NOT trust `window.scrollTo(0, 0)`. On a single-page app
the document itself is usually not the scroller — an inner container is —
so scrolling the window is a silent no-op on an element that was never
offset. It sweeps for whatever actually moved and names it in the log.
* `auto_dialog` answers every native dialog: alerts OK, confirms / prompts
OK only in LIVE-WRITE (Cancel in VERIFY: live eCW is read-only), and
beforeunload **dismissed**. An unhandled native dialog freezes the
renderer's JS thread, after which every Playwright call hangs with no
exception; accepting beforeunload means "yes, close this page", which has
been observed closing the working tab mid-flow.
"""
import re
from datetime import datetime
from playwright.sync_api import Page
from .. import settings as cfg
from . import run_events
# A shot taken because something went wrong — by its tag, as every caller
# names it: `claims_status_failed`, `claim_form_no_modal_<id>`,
# `login_unconfirmed`, `search_not_ready_<id>` ... Not `logged_in`,
# `landing`, `claims_status`, `progress_note`, `claims_balance_cleared`.
_FAILURE_TAG = re.compile(
r"fail|missing|not_|notfound|nodownload|(^|_)no_|unconfirmed|wrong|"
r"leftover|unreadable|timed_out|locked|disabled|lost|restricted|"
r"still_open|expired|error")
# DOM files written by this process (SAVE_DOM_LIMIT caps them per run).
_dom_saved = 0
def is_failure_tag(tag: str) -> bool:
return bool(_FAILURE_TAG.search(tag or ""))
def save_dom(page: Page, path) -> bool:
"""The page's HTML and every frame's, in one file. Never raises."""
try:
parts = [page.content()]
for n, frame in enumerate(page.frames[1:], start=1):
try:
parts.append(f"\n<!-- ===== frame {n}: {frame.name or ''} "
f"===== -->\n{frame.content()}")
except Exception:
pass
path.write_text("".join(parts), encoding="utf-8")
return True
except Exception:
return False
def shot(page: Page, tag: str) -> None:
"""Screenshot into Screenshots\\<MM-DD-YYYY>\\<HHMMSS>_<tag>.png — and,
for a failure (`is_failure_tag`), the DOM beside it as `.html`."""
global _dom_saved
ts = datetime.now().strftime("%H%M%S")
png = cfg.SCREENSHOTS / f"{ts}_{tag}.png"
try:
cfg.SCREENSHOTS.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(png))
if is_failure_tag(tag):
# The Runs page links a Run's failure shots.
run_events.emit("screenshot", path=str(png), tag=tag)
except Exception:
pass
if not (cfg.SAVE_DOM_ON_FAILURE and is_failure_tag(tag)
and _dom_saved < cfg.SAVE_DOM_LIMIT):
return
try:
cfg.SCREENSHOTS.mkdir(parents=True, exist_ok=True)
except Exception:
return
path = cfg.SCREENSHOTS / f"{ts}_{tag}.html"
if save_dom(page, path):
_dom_saved += 1
print(f"[DOM] Saved the page's HTML for the failure: {path}")
def visible(page: Page, selector: str, timeout: int = 500) -> bool:
"""
Whether any element matching `selector` is visible RIGHT NOW. Instant.
`timeout` is accepted for call compatibility and **ignored** — exactly as
it always was underneath: Playwright 1.62's `locator.is_visible(timeout=)`
does not wait (verified in this project; the review's item P8). Keeping
the parameter means no caller breaks, but nothing here pretends to wait.
A caller that means "give it time to show up" must use `appears()`.
Counts visible matches instead of asking `.first`: when eCW leaves a
hidden copy of an element ahead of the live one in the DOM, `.first` is
the hidden copy and the answer came back False with the real element on
screen. Never raises — "is it there?" is a question, not an assertion.
"""
try:
return page.locator(selector).filter(visible=True).count() > 0
except Exception:
return False
def appears(page: Page, selector: str, timeout: int = 5_000) -> bool:
"""
Wait up to `timeout` ms for an element matching `selector` to be visible.
Built on `wait_for(state="visible")`, the call that actually waits —
`is_visible(timeout=...)` looks like it should and does not (Playwright
1.62 ignores the timeout and answers instantly). That gotcha is why a slow
CPT tab was read short and a slow Prog. Notes button read as "absent"
(review items P8/D11).
Filtered to visible matches before `.first`, for the same reason as
`visible()`: a hidden leftover copy earlier in the DOM must not be the
one that gets waited on. Returns at once when it is already showing, so
the cost on the happy path is nothing. Never raises.
"""
try:
page.locator(selector).filter(visible=True).first.wait_for(
state="visible", timeout=timeout)
return True
except Exception:
return False
# Marks the one match a click would really land on: the shown element whose
# own centre hit-tests to itself (nothing covering it), or, when none does —
# off screen, or covered by something else entirely — the last one in the
# document (eCW's dialogs are appended, so later is higher). Returns how
# many shown matches there were. The mark is an attribute, removed from any
# earlier match first, so the locator below finds exactly one element.
#
# A match that computes `pointer-events: none` is not counted at all: no
# click can land on it (a window eCW is still opening, or one whose fade-out
# Chrome paused — live run 2026-10-13, ~45 s of Cancel clicks timing out).
_MARK_TOPMOST_JS = r"""
(s) => {
for (const el of document.querySelectorAll('[data-ecwbot-top]'))
el.removeAttribute('data-ecwbot-top');
const shown = el => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return r.width > 0 && r.height > 0
&& cs.visibility !== 'hidden' && cs.pointerEvents !== 'none';
};
const all = [...document.querySelectorAll(s)].filter(shown);
if (!all.length) return 0;
const onTop = el => {
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2,
r.top + r.height / 2);
return !!hit && (hit === el || el.contains(hit));
};
const top = all.filter(onTop).pop() || all[all.length - 1];
top.setAttribute('data-ecwbot-top', '1');
return all.length;
}
"""
_TOPMOST = '[data-ecwbot-top="1"]'
def count_shown(page: Page, selector: str) -> int:
"""How many matches are on screen right now (0 on any page error)."""
try:
return int(page.evaluate(_MARK_TOPMOST_JS, selector) or 0)
except Exception:
return 0
def click_topmost(page: Page, selector: str, timeout: int = 15_000) -> bool:
"""
Click the match that is on TOP — not `.first`, which is the earliest in
the document and, with two dialogs stacked, the one underneath. Live run
2026-10-12: a second Progress Note viewer and a second copy of an HCFA
claim window sat over the first; every `.first` click (Close, Cancel)
was intercepted for its full timeout, and the dialog that stayed blocked
the next claims too.
Returns False when nothing matching is on screen. A click that fails
raises, as `.click()` does — callers already handle that.
"""
if not count_shown(page, selector):
return False
page.locator(_TOPMOST).first.click(timeout=timeout)
return True
def text_of(page: Page, selector: str) -> str:
"""
An input's current value, or "?" when it cannot be read.
"?" is deliberately not "" — an unreadable control is *unknown*, and code
that treats unknown as empty will happily skip a filter that is still live.
"""
try:
return page.locator(selector).first.input_value(timeout=5_000).strip()
except Exception:
return "?"
def is_disabled(page: Page, selector: str) -> bool:
"""
Whether a control is disabled — and **True when it cannot be read**.
That default is deliberate and load-bearing. Its one caller decides between
typing into the Balance row and clearing it through Angular's scope; the
scope path works either way, so an unreadable control has to fall to the
safe side. Returning False here would make an unreadable control look
editable and the clear would silently do nothing.
"""
try:
return not page.locator(selector).first.is_enabled(timeout=5_000)
except Exception:
return True
# Every element is walked rather than a known list of containers: which div
# scrolls is a property of the page, not something this codebase can hardcode,
# and the whole point of the sweep is that it reports the answer instead of
# requiring someone to already know it.
_RESET_SCROLL_JS = r"""() => {
const out = [];
window.scrollTo(0, 0);
document.querySelectorAll('*').forEach(el => {
if (el.scrollTop > 0) {
out.push((el.tagName || '?').toLowerCase()
+ (el.id ? '#' + el.id : '')
+ ' @' + el.scrollTop);
el.scrollTop = 0;
}
});
return out;
}"""
def reset_scroll(page: Page) -> list:
"""
Scroll everything back to the top, and return what actually moved.
`window.scrollTo(0, 0)` was tried on its own first and did nothing at all:
the page came back still scrolled, which is what proved the document is not
the scroller here. An inner container is, so the window call is a no-op on
an element that was never offset in the first place.
The return value is the point as much as the scrolling is — each entry names
a container that was genuinely offset (`div#someGrid @240`), so the next
person reading a log does not have to rediscover which element scrolls.
Never raises: this is tidying up after a step, not the step itself.
"""
try:
return page.evaluate(_RESET_SCROLL_JS) or []
except Exception as exc:
print(f"[PAGE] Could not reset the scroll: "
f"{str(exc).splitlines()[0]}")
return []
def goto_with_retry(page: Page, url: str) -> None:
"""
The first navigation through the corporate proxy intermittently dies with
net::ERR_ABORTED — a transient proxy/TLS hiccup, not a bad URL.
"""
for attempt in range(1, cfg.NAV_RETRIES + 1):
try:
page.goto(url, wait_until="domcontentloaded", timeout=cfg.NAV_TIMEOUT)
return
except Exception as exc:
if attempt == cfg.NAV_RETRIES:
raise
print(f"[NAV] Navigation {attempt}/{cfg.NAV_RETRIES} failed "
f"({str(exc).splitlines()[0]}) — retrying in 5s")
page.wait_for_timeout(5_000)
def auto_dialog(dialog) -> None:
"""
Answer every native dialog: one left open wedges the renderer, and in
tests crashed the tab outright ("Target crashed"). Until 2026-10-22 none
was answered — `dialog.message()` called a property, and the TypeError
was swallowed.
* alert → OK: it only informs.
* confirm / prompt → OK only in LIVE-WRITE. In VERIFY, Cancel: live
eCW is read-only, and a confirm's OK may be a save.
* beforeunload → dismissed: accepting means "yes, close this page".
Each one is logged and is a Warning on the Runs page, with the answer.
"""
kind = getattr(dialog, "type", "?")
message = str(getattr(dialog, "message", "") or "")
accept = kind == "alert" or (kind in ("confirm", "prompt")
and bool(cfg.SAVE_CORRECTIONS))
answer = "OK" if accept else "Cancel"
print(f"[DIALOG] {kind}: {message!r} -> {answer}")
run_events.warning("dialog", f"{kind}: {message} -> {answer}"[:300])
try:
if accept:
dialog.accept()
else:
dialog.dismiss()
except Exception:
pass # answered already by the other listener
# (core/browser.py wires context and page)