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>
331 lines
13 KiB
Python
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)
|