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>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
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)
|
||||
Reference in new issue
Block a user