Files
ecw-charge-validation/tests/test_cdp_browser.py
T

412 lines
16 KiB
Python

"""
Attaching to a Chrome the bot did not start (BROWSER_CDP_URL).
python tests\\test_cdp_browser.py
The operator starts Chrome with `start_ecw_chrome.bat` (profile on D:, port
9222 on 127.0.0.1) and adds the Claude extension to it, so a run can be
watched live. The bot then attaches over CDP instead of launching its own
Chromium. What has to hold, each checked against a real Chromium started the
same way the .bat starts Chrome:
* **Only a loopback URL is accepted.** Anything that can reach the port can
drive a browser logged in to eCW.
* **eCW's cookies and storage go at the end of a run, and at the start**
(a run killed half-way leaves them behind). Only eCW's: claude.ai's
cookie is what keeps the extension signed in.
* **close() leaves Chrome running** and the operator's own tab open — it
closes the bot's tabs and disconnects.
* **Dialogs and downloads still work** on Chrome's own context, which the
bot cannot create with its usual options.
"""
import io
import os
import sys
import contextlib
import time
import socket
import pathlib
import tempfile
import subprocess
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 browser # noqa: E402
ECW = "https://app1.ecwcloud.com"
def test_cdp_url_must_be_loopback():
for ok in ("http://127.0.0.1:9222", "http://localhost:9222",
"http://127.0.0.1:9222/"):
assert cfg.cdp_url_problem(ok) is None, ok
for bad in ("http://10.100.0.23:9222", "http://0.0.0.0:9222",
"http://example.com:9222", "127.0.0.1:9222",
"http://127.0.0.1", "ws://127.0.0.1:9222"):
assert cfg.cdp_url_problem(bad), bad
def test_ecw_sites_are_the_registrable_domain():
assert browser.ecw_sites(["https://abc.ecwcloud.com/mobiledoc/x",
"https://abc.ecwcloud.com", ""]) == {"ecwcloud.com"}
assert browser.is_ecw_host("abc.ecwcloud.com", {"ecwcloud.com"})
assert browser.is_ecw_host(".ecwcloud.com", {"ecwcloud.com"})
assert not browser.is_ecw_host("claude.ai", {"ecwcloud.com"})
assert not browser.is_ecw_host("notecwcloud.com", {"ecwcloud.com"})
def test_start_script_command():
sys.path.insert(0, str(ROOT / "tools"))
import start_ecw_chrome as s
assert s.chrome_command is browser.chrome_command, "one command, two users"
cmd = s.chrome_command("chrome.exe", 9222, r"D:\EcwChrome",
{"server": "http://10.0.0.1:8091"})
assert "--remote-debugging-port=9222" in cmd, cmd
assert r"--user-data-dir=D:\EcwChrome" in cmd, cmd
assert "--proxy-server=http://10.0.0.1:8091" in cmd, cmd
assert any(a.startswith("--proxy-bypass-list=") and "claude.ai" in a
for a in cmd), cmd
assert all(a in cmd for a in browser.CHROME_ARGS), cmd
assert not any(a.startswith("--proxy")
for a in s.chrome_command("c", 9222, "p", None))
def test_a_chrome_with_the_proxy_passes_and_one_without_is_refused():
saved = (browser._cmdline_on_port, cfg.PROXY_IP, cfg.PROXY_PORT)
try:
cfg.PROXY_IP, cfg.PROXY_PORT = "10.0.0.1", "8091"
browser._cmdline_on_port = lambda port: (
"chrome.exe --remote-debugging-port=9222 "
"--proxy-server=http://10.0.0.1:8091")
assert browser.chrome_proxy_problem("http://127.0.0.1:9222") is None
browser._cmdline_on_port = lambda port: (
"chrome.exe --remote-debugging-port=9222")
assert "without the eCW proxy" in browser.chrome_proxy_problem(
"http://127.0.0.1:9222")
browser._cmdline_on_port = lambda port: None # cannot tell
with contextlib.redirect_stdout(io.StringIO()):
assert browser.chrome_proxy_problem("http://127.0.0.1:9222") \
is None
cfg.PROXY_IP = "" # no proxy at all
assert browser.chrome_proxy_problem("http://127.0.0.1:9222") is None
finally:
browser._cmdline_on_port, cfg.PROXY_IP, cfg.PROXY_PORT = saved
def test_cdp_ready_false_when_nothing_listens():
assert browser.cdp_ready(f"http://127.0.0.1:{_free_port()}") is False
# ── Against a real Chromium on a debug port ───────────────────────────────────
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _routes(context):
"""Serve the fake eCW and claude.ai origins without a network."""
def handle(route):
route.fulfill(status=200, content_type="text/html",
body="<html><body>ok</body></html>")
context.route(f"{ECW}/**", handle)
context.route("https://claude.ai/**", handle)
def _seed(pw, url):
"""Leave eCW and claude.ai data in the profile, as a run would."""
b = pw.chromium.connect_over_cdp(url)
ctx = b.contexts[0]
_routes(ctx)
p = ctx.new_page()
p.goto(f"{ECW}/mobiledoc/")
p.evaluate("localStorage.setItem('ecw', '1')")
ctx.add_cookies([
{"name": "JSESSIONID", "value": "x", "domain": "app1.ecwcloud.com",
"path": "/", "secure": True},
{"name": "dev", "value": "y", "domain": ".ecwcloud.com", "path": "/",
"secure": True},
{"name": "sessionKey", "value": "z", "domain": "claude.ai",
"path": "/", "secure": True},
])
p.close()
b.close()
def _cookie_names(ctx):
return sorted(c["name"] for c in ctx.cookies())
def _ecw_local_storage(ctx):
_routes(ctx)
p = ctx.new_page()
p.goto(f"{ECW}/mobiledoc/")
val = p.evaluate("localStorage.getItem('ecw')")
p.close()
return val
def case_attach_clears_leftover_ecw_data(pw, url):
_seed(pw, url)
b, ctx, page = browser.launch(pw)
try:
assert _cookie_names(ctx) == ["sessionKey"], _cookie_names(ctx)
assert page.context is ctx
# The operator's Chrome: its blank tab is theirs, never closed.
assert len(ctx.pages) >= 2 and any(
p.url == "about:blank" for p in ctx.pages if p is not page), \
[p.url for p in ctx.pages]
finally:
b.close()
def case_close_clears_ecw_only_and_keeps_chrome(pw, url):
b, ctx, page = browser.launch(pw)
_routes(ctx)
page.goto(f"{ECW}/mobiledoc/")
page.evaluate("localStorage.setItem('ecw', '1')")
ctx.add_cookies([
{"name": "JSESSIONID", "value": "x", "domain": "app1.ecwcloud.com",
"path": "/", "secure": True},
{"name": "sessionKey", "value": "z", "domain": "claude.ai",
"path": "/", "secure": True},
])
popup = ctx.new_page() # a window eCW opened
b.close()
assert popup.is_closed() and page.is_closed()
assert browser.cdp_ready(url), "Chrome went away with the bot"
look = pw.chromium.connect_over_cdp(url)
try:
ctx2 = look.contexts[0]
assert _cookie_names(ctx2) == ["sessionKey"], _cookie_names(ctx2)
assert _ecw_local_storage(ctx2) is None
assert len(ctx2.pages) >= 1, "the operator's own tab was closed"
finally:
look.close()
def case_dialog_handler_is_wired(pw, url):
# The same handler as a launched browser gets, on the bot's tab and on
# every tab eCW opens. A stand-in records it: what the real handler does
# with a dialog is page_utils' business, not this file's.
seen = []
def record(dialog):
seen.append(dialog.type)
dialog.accept()
# Kept in place until the end: a new tab looks the handler up when it
# opens, not when the bot attached.
real, browser.auto_dialog = browser.auto_dialog, record
b = None
try:
b, ctx, page = browser.launch(pw)
page.set_content("<p>x</p>")
page.evaluate("alert('one')")
popup = ctx.new_page()
popup.set_content("<p>y</p>")
popup.evaluate("alert('two')")
assert seen.count("alert") >= 2, seen
finally:
if b:
b.close()
browser.auto_dialog = real
def case_download_over_cdp(pw, url):
b, ctx, page = browser.launch(pw)
try:
page.set_content('<a id="d" download="r.csv" '
'href="data:text/csv,a%2Cb%0A1%2C2">get</a>')
with page.expect_download(timeout=15_000) as info:
page.click("#d")
out = pathlib.Path(tempfile.mkdtemp()) / "r.csv"
info.value.save_as(out)
assert out.read_text() == "a,b\n1,2", out.read_text()
finally:
b.close()
CASES = [
case_attach_clears_leftover_ecw_data,
case_close_clears_ecw_only_and_keeps_chrome,
case_dialog_handler_is_wired,
case_download_over_cdp,
]
def _start_chrome(exe, port, profile):
proc = subprocess.Popen(
[exe, f"--remote-debugging-port={port}", f"--user-data-dir={profile}",
"--headless=new", "--no-first-run", "--no-default-browser-check",
"about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
url = f"http://127.0.0.1:{port}"
for _ in range(60):
if browser.cdp_ready(url):
return proc, url
time.sleep(0.25)
proc.kill()
raise RuntimeError("Chromium did not open its debug port")
def _run_live() -> int:
from playwright.sync_api import sync_playwright
failures = 0
base = "D:/claude_tmp" if os.path.isdir("D:/claude_tmp") else None
profile = tempfile.mkdtemp(prefix="cdp_profile_", dir=base)
saved = (cfg.BROWSER_CDP_URL, cfg.ECW_URL, cfg.ECW_LANDING_URL)
with sync_playwright() as pw:
proc, url = _start_chrome(pw.chromium.executable_path, _free_port(),
profile)
cfg.BROWSER_CDP_URL, cfg.ECW_URL, cfg.ECW_LANDING_URL = (
url, f"{ECW}/mobiledoc/jsp/webemr/login/newLogin.jsp", ECW)
try:
for fn in CASES:
name = fn.__name__.replace("case_", "").replace("_", " ")
try:
fn(pw, url)
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
finally:
cfg.BROWSER_CDP_URL, cfg.ECW_URL, cfg.ECW_LANDING_URL = saved
proc.kill()
proc.wait()
return failures
def _pid_on_port(port: int):
"""(pid, parent pid) of the process listening on `port`."""
ps = (f"$c = Get-NetTCPConnection -LocalPort {port} -State Listen "
f"-ErrorAction SilentlyContinue | Select-Object -First 1; "
f"if ($c) {{ $p = Get-CimInstance Win32_Process -Filter "
f"\"ProcessId=$($c.OwningProcess)\"; "
f"\"$($p.ProcessId) $($p.ParentProcessId)\" }}")
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True).stdout.split()
return (int(out[0]), int(out[1])) if len(out) == 2 else (None, None)
def _gone(url, seconds=15):
end = time.monotonic() + seconds
while time.monotonic() < end:
if not browser.cdp_ready(url):
return True
time.sleep(0.25)
return False
def _run_bot_starts_chrome() -> int:
"""Start pressed, no Chrome on the port: the bot opens it, outside its
own process tree (a Stop's taskkill /T must not take it), attaches, and
closes it at the end — only the one it opened."""
from playwright.sync_api import sync_playwright
failures = 0
base = "D:/claude_tmp" if os.path.isdir("D:/claude_tmp") else None
saved = (cfg.BROWSER_CDP_URL, cfg.CHROME_PATH, cfg.CDP_PROFILE_DIR,
cfg.PROXY_IP, cfg.CDP_CLOSE_AFTER_RUN, cfg.ECW_URL,
cfg.ECW_LANDING_URL, cfg.PROXY_PORT)
with sync_playwright() as pw:
try:
cfg.CHROME_PATH = pw.chromium.executable_path
cfg.PROXY_IP = ""
cfg.ECW_URL = cfg.ECW_LANDING_URL = ECW
for close_after in (True, False):
name = ("the bot opens Chrome, outside its tree, and "
+ ("closes it after the run" if close_after
else "leaves it with CDP_CLOSE_AFTER_RUN=0"))
port = _free_port()
url = f"http://127.0.0.1:{port}"
cfg.BROWSER_CDP_URL = url
cfg.CDP_PROFILE_DIR = tempfile.mkdtemp(prefix="cdp_bot_",
dir=base)
cfg.CDP_CLOSE_AFTER_RUN = close_after
pid = None
try:
assert browser.start_chrome(url, ["--headless=new"])
assert browser.cdp_ready(url)
pid, parent = _pid_on_port(port)
assert pid and parent != os.getpid(), (pid, parent)
# Opened with no proxy: fine while none is configured,
# refused once the eCW proxy is (the 2026-10-22 case).
assert browser.chrome_proxy_problem(url) is None
cfg.PROXY_IP, cfg.PROXY_PORT = "10.0.0.1", "8091"
problem = browser.chrome_proxy_problem(url)
cfg.PROXY_IP, cfg.PROXY_PORT = "", ""
assert problem and "without the eCW proxy" in problem, \
problem
b, ctx, page = browser.launch(pw)
# The Chrome the run opened shows eCW only: its own
# empty first tab is closed (operator, 2026-10-23).
assert ctx.pages == [page], [p.url for p in ctx.pages]
page.set_content("<p>x</p>")
b.close()
if close_after:
# close() returns once Chrome is gone, not while it
# is still shutting down.
assert not browser.cdp_ready(url), \
"Chrome still answers after close()"
# A login retry (2FA timed out) launches again: a
# fresh Chrome, not the dying one (2026-10-27 16:50).
b, ctx, page = browser.launch(pw)
page.set_content("<p>again</p>")
b.close()
assert not browser.cdp_ready(url), \
"the reopened Chrome still answers"
else:
time.sleep(1)
assert browser.cdp_ready(url), "Chrome was closed"
print(f" ok {name}")
except Exception as exc:
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
failures += 1
finally:
if browser.cdp_ready(url):
pid, _ = _pid_on_port(port)
if pid:
subprocess.run(["taskkill", "/PID", str(pid),
"/T", "/F"], capture_output=True)
finally:
(cfg.BROWSER_CDP_URL, cfg.CHROME_PATH, cfg.CDP_PROFILE_DIR,
cfg.PROXY_IP, cfg.CDP_CLOSE_AFTER_RUN, cfg.ECW_URL,
cfg.ECW_LANDING_URL, cfg.PROXY_PORT) = saved
return failures
def main() -> int:
plain = [test_cdp_url_must_be_loopback,
test_ecw_sites_are_the_registrable_domain,
test_start_script_command,
test_a_chrome_with_the_proxy_passes_and_one_without_is_refused,
test_cdp_ready_false_when_nothing_listens]
failures = 0
for fn in plain:
name = fn.__name__.replace("test_", "").replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
failures += _run_live()
failures += _run_bot_starts_chrome()
total = len(plain) + len(CASES) + 2
print(f"\n{total - failures}/{total} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())