"""Test pages without a browser.
client = TestClient(app)
page = client.open("/")
page.click("Increment")
assert "Count: 1" in page.text
Events go through the real HTTP endpoint (cookies, CSRF, validation); patches
are applied to a lightweight DOM so later lookups see the updated page.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from html.parser import HTMLParser
from typing import Any
from starlette.testclient import TestClient as _StarletteClient
from .rendering.html import VOID_TAGS
class El:
def __init__(self, tag: str, attrs: dict[str, str], parent: El | None = None) -> None:
self.tag = tag
self.attrs = attrs
self.parent = parent
self.children: list[El | str] = []
@property
def text(self) -> str:
parts = [
c if isinstance(c, str) else c.text
for c in self.children
if isinstance(c, str) or c.tag not in ("script", "style", "title")
]
return " ".join(" ".join(parts).split())
def iter(self) -> Iterator[El]:
for child in self.children:
if isinstance(child, El):
yield child
yield from child.iter()
def find_all(self, tag: str | None = None, **attrs: str) -> list[El]:
out = []
for el in self.iter():
if tag is not None and el.tag != tag:
continue
if all(el.attrs.get(k.replace("_", "-")) == v for k, v in attrs.items()):
out.append(el)
return out
def get(self, name: str) -> str | None:
return self.attrs.get(name)
def __repr__(self) -> str:
return f"<{self.tag} {self.attrs}>"
class _Parser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root = El("#root", {})
self.current = self.root
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
el = El(tag, {k: "" if v is None else v for k, v in attrs}, self.current)
self.current.children.append(el)
if tag not in VOID_TAGS:
self.current = el
def handle_endtag(self, tag: str) -> None:
node: El | None = self.current
while node is not None and node.tag != tag:
node = node.parent
if node is not None and node.parent is not None:
self.current = node.parent
def handle_data(self, data: str) -> None:
self.current.children.append(data)
def parse(html: str) -> El:
parser = _Parser()
parser.feed(html)
parser.close()
return parser.root
[docs]
class EventFailedError(AssertionError):
pass
class PageView:
def __init__(self, client: TestClient, path: str, response: Any) -> None:
self.client = client
self.path = path
self.status_code: int = response.status_code
self.dom = parse(response.text)
manifest = self.dom.find_all("script", id="rw-manifest")
self.manifest: dict[str, Any] | None = json.loads(manifest[0].text) if manifest else None
self.last: dict[str, Any] = {}
@property
def text(self) -> str:
body = self.dom.find_all("body")
return (body[0] if body else self.dom).text
@property
def html(self) -> str:
return _serialize(self.dom)
# -- finding -----------------------------------------------------------------------------
def find(self, text: str, event: str) -> El:
"""The element handling ``event`` whose accessible name (``aria-label``, else its text) contains
``text``; the closest match wins."""
attr = f"data-rw-on-{event}"
named = [(el, el.get("aria-label") or el.text) for el in self.dom.iter() if attr in el.attrs]
matches = [(el, name) for el, name in named if text in name]
if not matches:
raise LookupError(f"no element with {attr} named {text!r}")
best: El = min(matches, key=lambda pair: len(pair[1]))[0]
return best
def field(self, label: str) -> El:
for lab in self.dom.find_all("label"):
if label in lab.text and lab.get("for"):
found = self.dom.find_all(id=lab.get("for") or "")
if found:
return found[0]
raise LookupError(f"no form control labelled {label!r}")
# -- interacting ---------------------------------------------------------------------------
def click(self, text: str) -> dict[str, Any]:
return self.fire(self.find(text, "click").attrs["data-rw-on-click"], {})
def input(self, label: str, value: Any, event: str = "input") -> dict[str, Any]:
el = self.field(label)
handler = el.get(f"data-rw-on-{event}")
if handler is None:
raise LookupError(f"control {label!r} has no on_{event} handler")
return self.fire(handler, {"value": value})
def submit(self, data: dict[str, Any], *, button: str | None = None, check: bool = True) -> dict[str, Any]:
forms = [f for f in self.dom.find_all("form") if "data-rw-on-submit" in f.attrs]
if button is not None:
forms = [f for f in forms if button in f.text]
if not forms:
raise LookupError("no form with an on_submit handler")
return self.fire(forms[0].attrs["data-rw-on-submit"], {"form": data}, check=check)
def fire(self, handler: str, payload: dict[str, Any], *, check: bool = True) -> dict[str, Any]:
if self.manifest is None:
raise EventFailedError("page is not interactive")
response = self.client.http.post(
self.manifest["endpoint"],
json={
"v": self.manifest["v"],
"page": self.manifest["page"],
"rev": self.manifest["rev"],
"handler": handler,
"payload": payload,
},
headers={"X-RW-CSRF": self.manifest["csrf"]},
)
data: dict[str, Any] = response.json()
self.last = data
self.manifest["rev"] = data.get("rev", self.manifest["rev"])
if data.get("csrf"):
self.manifest["csrf"] = data["csrf"]
for patch in data.get("patches", []):
self._apply(patch)
if (
check
and "error" in data
and data["error"]["code"]
not in (
"validation_error",
"stale",
"stale_revision",
)
):
raise EventFailedError(f"{response.status_code}: {data['error']}")
return data
def _apply(self, patch: dict[str, Any]) -> None:
targets = self.dom.find_all(data_rw_c=patch["id"])
if not targets:
return
old = targets[0]
new = parse(patch["html"]).children[0]
assert isinstance(new, El) and old.parent is not None
index = old.parent.children.index(old)
new.parent = old.parent
old.parent.children[index] = new
class TestClient:
__test__ = False # not a pytest test class
def __init__(self, app: Any) -> None:
self.app = app
self.http = _StarletteClient(app)
def __enter__(self) -> TestClient:
self.http.__enter__()
return self
def __exit__(self, *exc: Any) -> None:
self.http.__exit__(*exc)
def open(self, path: str) -> PageView:
return PageView(self, path, self.http.get(path, headers={"accept": "text/html"}))
def _serialize(el: El) -> str:
from .rendering.html import escape_attr, escape_text
parts: list[str] = []
for child in el.children:
if isinstance(child, str):
parts.append(escape_text(child))
continue
attrs = "".join(f' {k}="{escape_attr(v)}"' for k, v in child.attrs.items())
parts.append(f"<{child.tag}{attrs}>")
if child.tag not in VOID_TAGS:
parts.append(_serialize(child) + f"</{child.tag}>")
return "".join(parts)