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,198 @@
|
||||
r"""
|
||||
eCW's post-login "This password expires in N days" box — answered with
|
||||
Cancel, never OK, so the login reaches the home screen.
|
||||
|
||||
python tests\test_password_expiry_prompt.py
|
||||
|
||||
Seen live 2026-10-05: both runs that day got through the 2FA approval and
|
||||
then stopped on this box (Screenshots\10-05-2026\*_login_unconfirmed.png),
|
||||
reporting "Login not confirmed" after two minutes. The box's real DOM was
|
||||
not captured, so the fixtures below are made-up markup with eCW's wording;
|
||||
the code finds the box by its text, which is what these pin.
|
||||
|
||||
Local headless chromium, `page.set_content()` only. No eCW, no login.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
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.auth import login # noqa: E402
|
||||
from ecw_bot.selectors import login as sel # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
# The box over a page whose home link only appears once the box is gone —
|
||||
# the shape seen live. OK sets a flag (it must never be clicked); the page
|
||||
# also has an unrelated Cancel button outside the box.
|
||||
_PROMPT = """
|
||||
<button id="otherCancel" onclick="window.otherCancel = true">Cancel</button>
|
||||
<a id="jellybean-panelLink4" style="display:none">Home</a>
|
||||
<div id="ecwAlert" class="modal" style="display:none">
|
||||
<div class="modal-header">eClinicalWorks</div>
|
||||
<div class="modal-body"><span>This password expires in 14 days. Do you
|
||||
want to change the password now?</span></div>
|
||||
<div class="modal-footer">
|
||||
<button id="ok" onclick="window.okClicked = true">OK</button>
|
||||
<button id="cancel" onclick="
|
||||
document.getElementById('ecwAlert').remove();
|
||||
document.getElementById('jellybean-panelLink4').style.display = '';
|
||||
"> Cancel </button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.okClicked = false; window.otherCancel = false;
|
||||
setTimeout(() => {
|
||||
document.getElementById('ecwAlert').style.display = '';
|
||||
}, %d);
|
||||
</script>
|
||||
"""
|
||||
|
||||
_INPUT_BUTTONS = """
|
||||
<a id="jellybean-panelLink4" style="display:none">Home</a>
|
||||
<table id="box"><tr><td>This password expires in 3 days. Do you want to
|
||||
change the password now?</td></tr>
|
||||
<tr><td><input type="button" value="OK" onclick="window.okClicked = true">
|
||||
<input type="button" value="Cancel" onclick="
|
||||
document.getElementById('box').remove();
|
||||
document.getElementById('jellybean-panelLink4').style.display = '';
|
||||
"></td></tr></table>
|
||||
<script>window.okClicked = false;</script>
|
||||
"""
|
||||
|
||||
_EXPIRED = """
|
||||
<div>Your password has expired. Please set a new password.</div>
|
||||
<button>OK</button>
|
||||
"""
|
||||
|
||||
_NO_CANCEL = """
|
||||
<div>This password expires in 5 days.</div>
|
||||
<button onclick="window.okClicked = true">OK</button>
|
||||
<script>window.okClicked = false;</script>
|
||||
"""
|
||||
|
||||
|
||||
def _quiet(fn, *args):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
result = fn(*args)
|
||||
return result, out.getvalue()
|
||||
|
||||
|
||||
@case
|
||||
def a_late_prompt_is_cancelled_and_the_home_screen_is_reached(page):
|
||||
page.set_content(_PROMPT % 700)
|
||||
ok, log = _quiet(login.wait_for_home, page, 8_000)
|
||||
assert ok is True, log
|
||||
assert page.evaluate("window.okClicked") is False, "OK was clicked"
|
||||
assert page.evaluate("window.otherCancel") is False, \
|
||||
"clicked a Cancel outside the box"
|
||||
assert "expires in 14 day(s)" in log, log
|
||||
assert "Answered the password-expiry box with Cancel" in log, log
|
||||
|
||||
|
||||
@case
|
||||
def input_buttons_work_too(page):
|
||||
page.set_content(_INPUT_BUTTONS)
|
||||
ok, log = _quiet(login.wait_for_home, page, 5_000)
|
||||
assert ok is True, log
|
||||
assert page.evaluate("window.okClicked") is False
|
||||
|
||||
|
||||
@case
|
||||
def no_prompt_means_nothing_is_clicked(page):
|
||||
page.set_content('<a id="jellybean-panelLink4">Home</a>'
|
||||
'<button onclick="window.c = true">Cancel</button>'
|
||||
'<script>window.c = false;</script>')
|
||||
result, _ = _quiet(login.dismiss_password_expiry_prompt, page)
|
||||
assert result is None
|
||||
ok, _ = _quiet(login.wait_for_home, page, 2_000)
|
||||
assert ok is True and page.evaluate("window.c") is False
|
||||
|
||||
|
||||
@case
|
||||
def a_hidden_prompt_is_ignored(page):
|
||||
page.set_content(_PROMPT % 60_000) # never shown within the test
|
||||
result, _ = _quiet(login.dismiss_password_expiry_prompt, page)
|
||||
assert result is None
|
||||
|
||||
|
||||
@case
|
||||
def an_expired_password_stops_the_login_with_the_reason(page):
|
||||
page.set_content(_EXPIRED)
|
||||
try:
|
||||
_quiet(login.dismiss_password_expiry_prompt, page)
|
||||
except RuntimeError as exc:
|
||||
assert "password has expired" in str(exc), exc
|
||||
assert "never sets a password" in str(exc), exc
|
||||
else:
|
||||
raise AssertionError("an expired password did not raise")
|
||||
|
||||
|
||||
@case
|
||||
def a_prompt_without_cancel_is_reported_and_ok_is_never_clicked(page):
|
||||
page.set_content(_NO_CANCEL)
|
||||
ok, log = _quiet(login.wait_for_home, page, 1_500)
|
||||
assert ok is False
|
||||
assert page.evaluate("window.okClicked") is False, "OK was clicked"
|
||||
assert log.count("no Cancel button") == 3, log # capped, not every poll
|
||||
|
||||
|
||||
@case
|
||||
def the_patterns_match_the_live_wording():
|
||||
import re
|
||||
live = ("This password expires in 14 days. Do you want to change the "
|
||||
"password now?")
|
||||
m = re.search(sel.TXT_PASSWORD_EXPIRES, live, re.I)
|
||||
assert m and m.group(1) == "14"
|
||||
assert not re.search(sel.TXT_PASSWORD_EXPIRED, live, re.I)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(" SKIP playwright is not installed")
|
||||
return 0
|
||||
|
||||
import inspect
|
||||
failures = 0
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg.SCREENSHOTS = pathlib.Path(tmp) # never the real folder
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
for fn in CASES:
|
||||
name = fn.__name__.replace("_", " ")
|
||||
try:
|
||||
if inspect.signature(fn).parameters:
|
||||
fn(page)
|
||||
else:
|
||||
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
|
||||
browser.close()
|
||||
|
||||
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user