diff --git a/priv/python/albedo_plugins/browser/page.py b/priv/python/albedo_plugins/browser/page.py index 98163fd0..75380f2e 100644 --- a/priv/python/albedo_plugins/browser/page.py +++ b/priv/python/albedo_plugins/browser/page.py @@ -682,7 +682,7 @@ class Page: frame_id: str | None = None, timeout: float | None = None, ) -> Any: - """Evaluate an expression or a zero-argument function; await promises; return data. + """Evaluate an expression, function, or statement body; await promises; return data. This is a raw escape hatch: it may mutate the page and bypasses action locking. Explicitly invoke functions taking arguments in the supplied expression. @@ -706,6 +706,14 @@ class Page: ) params["contextId"] = world["executionContextId"] result = await cdp.send("Runtime.evaluate", params, timeout=timeout) + detail = result.get("exceptionDetails", {}) + # Parse failures have no stack; a runtime SyntaxError must not run twice. + if ( + detail.get("exception", {}).get("className") == "SyntaxError" + and "stackTrace" not in detail + ): + params["expression"] = f"(async () => {{\n{expression}\n}})()" + result = await cdp.send("Runtime.evaluate", params, timeout=timeout) return self._value(result) @reported("browser.goto", {"url": "url"}) @@ -875,11 +883,17 @@ class Page: return x, y async def _click_point( - self, record: Reference, state: FrameSession, object_id: str + self, + record: Reference, + state: FrameSession, + object_id: str, + *, + scroll: bool = True, ) -> tuple[float, float]: - await state.cdp.send( - "DOM.scrollIntoViewIfNeeded", {"backendNodeId": record.backend_id} - ) + if scroll: + await state.cdp.send( + "DOM.scrollIntoViewIfNeeded", {"backendNodeId": record.backend_id} + ) quads = ( await state.cdp.send( "DOM.getContentQuads", {"backendNodeId": record.backend_id} @@ -959,34 +973,64 @@ class Page: "name": str(node.get("name", {}).get("value", ""))[:80], } - @reported("browser.click", {"ref": "ref"}) - async def click(self, ref: str | Node, *, button: str = "left") -> JSON: - if button not in {"left", "right", "middle"}: - raise ValueError("button must be left, right, or middle") - async with self._action("click", ref) as receipt: - async with self._resolved(ref) as (key, record, state, obj, status): - if status["disabled"]: - raise NotActionable("Element is disabled.", ref=key) - x, y = await self._click_point(record, state, obj) - self._ref(key) - receipt["may_have_executed"] = True - pressed = False - try: - await self.cdp.send( - "Input.dispatchMouseEvent", - {"type": "mouseMoved", "x": x, "y": y}, - ) - pressed = True - await self.cdp.send( - "Input.dispatchMouseEvent", - { - "type": "mousePressed", - "x": x, - "y": y, - "button": button, - "clickCount": 1, - }, - ) + async def _point( + self, target: str | Node | tuple[float, float], *, scroll: bool = True + ) -> tuple[float, float]: + if isinstance(target, tuple): + if len(target) != 2 or not all( + isinstance(v, (int, float)) and math.isfinite(v) for v in target + ): + raise ValueError("point must be a finite (x, y) pair") + return target + async with self._resolved(target) as (key, record, state, obj, status): + if status["disabled"]: + raise NotActionable("Element is disabled.", ref=key) + point = await self._click_point(record, state, obj, scroll=scroll) + self._ref(key) + return point + + async def _mouse_gesture( + self, points: list[tuple[float, float]], button: str, receipt: JSON + ) -> None: + x, y = points[0] + pressed = False + failed = True + receipt["may_have_executed"] = True + try: + await self.cdp.send( + "Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y} + ) + pressed = True + await self.cdp.send( + "Input.dispatchMouseEvent", + { + "type": "mousePressed", + "x": x, + "y": y, + "button": button, + "clickCount": 1, + }, + ) + for x, y in points[1:]: + await self.cdp.send( + "Input.dispatchMouseEvent", + { + "type": "mouseMoved", + "x": x, + "y": y, + "button": button, + "buttons": {"left": 1, "right": 2, "middle": 4}[button], + }, + ) + failed = False + finally: + if pressed and not self.browser._connection.closed: + # Release normally too; cleanup must not replace a move's error. + with ( + contextlib.suppress(Exception) + if failed + else contextlib.nullcontext() + ): await self.cdp.send( "Input.dispatchMouseEvent", { @@ -996,22 +1040,63 @@ class Page: "button": button, "clickCount": 1, }, + timeout=1, ) - pressed = False - finally: - if pressed and not self.browser._connection.closed: - with contextlib.suppress(Exception): - await self.cdp.send( - "Input.dispatchMouseEvent", - { - "type": "mouseReleased", - "x": x, - "y": y, - "button": button, - "clickCount": 1, - }, - timeout=1, - ) + + @reported("browser.click", {"ref": "ref"}) + async def click( + self, ref: str | Node | tuple[float, float], *, button: str = "left" + ) -> JSON: + """Click an observed node or a viewport (x, y) point.""" + if button not in {"left", "right", "middle"}: + raise ValueError("button must be left, right, or middle") + async with self._action( + "click", None if isinstance(ref, tuple) else ref + ) as receipt: + await self._mouse_gesture([await self._point(ref)], button, receipt) + return copy.deepcopy(receipt) + + async def click_text(self, text: str) -> JSON: + """Click the unique button or link whose accessible name contains text.""" + if not isinstance(text, str) or not text: + raise ValueError("text must be a nonempty string") + observation = await self.observe() + matches = [ + node + for node in find(observation, name=text, exact=False) + if node["role"] in {"button", "link"} + ] + if len(matches) != 1: + raise SelectionError( + f"Expected one button or link containing {text!r}, found {len(matches)}.", + matches=matches[:10], + observation_complete=observation["coverage"].get("complete", False), + ) + return await self.click(matches[0]) + + @reported("browser.drag", {"start": "start", "end": "end"}) + async def drag( + self, + start: str | Node | tuple[float, float], + end: str | Node | tuple[float, float], + *, + steps: int = 20, + ) -> JSON: + """Drag between nodes or viewport points with the left button held.""" + if ( + isinstance(steps, bool) + or not isinstance(steps, int) + or not 1 <= steps <= 1000 + ): + raise ValueError("steps must be an integer from 1 to 1000") + async with self._action("drag") as receipt: + x0, y0 = await self._point(start) + x1, y1 = await self._point(end, scroll=False) + points = [ + (x0 + (x1 - x0) * i / steps, y0 + (y1 - y0) * i / steps) + for i in range(steps + 1) + ] + await self._mouse_gesture(points, "left", receipt) return copy.deepcopy(receipt) @reported("browser.fill", {"ref": "ref", "text": "text"}) diff --git a/robot-docs/kernel.md b/robot-docs/kernel.md index eea16eb6..a37c6503 100644 --- a/robot-docs/kernel.md +++ b/robot-docs/kernel.md @@ -50,6 +50,23 @@ file path into memory. Invalid, cut-short, or over-edge attachments still raise. No image decoder is required, so overflow is not tiled. +## browser gestures + +`priv/python/albedo_plugins/browser/page.py` owns page actions and their lock. +Use `await page.click((x, y))` for viewport coordinates and +`await page.drag(start, end, steps=20)` for left-button drags. Endpoints may be +observed nodes, references, or viewport `(x, y)` points; node endpoints must +both fit in the viewport. Drag steps are 1–1000, and mouse release runs even +if a move fails. Document-level mouse/pointer listeners do not need capture. +Use `await page.click_text(text)` to click a unique button or link whose +accessible name contains the case-sensitive text. Zero or multiple matches +raise `SelectionError` rather than choosing one. + +`await page.evaluate(source)` accepts expressions, zero-argument functions, and +statement bodies such as `localStorage.clear(); return 1`. It selects the syntax +before running the source, so a runtime exception never causes a retry. +Promises are awaited. This escape hatch still bypasses action locking. + ## spawned browsers `priv/python/albedo_plugins/browser/launch.py` owns browser discovery and spawned diff --git a/src/albedo/harness/extensions/browser/extension.gleam b/src/albedo/harness/extensions/browser/extension.gleam index 56c4ec8c..4518e42e 100644 --- a/src/albedo/harness/extensions/browser/extension.gleam +++ b/src/albedo/harness/extensions/browser/extension.gleam @@ -14,6 +14,9 @@ pub fn extension() -> extension.Extension { <> " await page.select(node, value), await page.hover(node), await page.press(key)," <> " await page.wait_for(...), and await page.screenshot()." <> " browser.find(snapshot, ...) and browser.one(snapshot, ...) locate nodes in an observation." + <> " Use await page.click((x, y)) for coordinates, await page.drag(start, end, steps=20) for drags (observed nodes or viewport points)," + <> " and await page.click_text(text) for a unique button/link whose accessible name contains text; none or several raises SelectionError." + <> " await page.evaluate(source) accepts expressions, zero-argument functions, or statement bodies such as 'localStorage.clear(); return 1'." <> " Finish with await b.close(). Failures raise BrowserError.", ["python"], ) diff --git a/test/e2e/browser_test.py b/test/e2e/browser_test.py index 49b9f72d..323f8225 100644 --- a/test/e2e/browser_test.py +++ b/test/e2e/browser_test.py @@ -1,6 +1,10 @@ """Browser discovery through the real kernel, without starting a GUI app.""" import unittest +import http.server +import threading +import shutil +from pathlib import Path from harness import Albedo, Provider, exclusive, python, text @@ -81,3 +85,120 @@ Path({str(app.workspace / "passed")!r}).write_text('passed') app.prompt(session, "try the installed browsers").close() app.idle(session) self.assertTrue((app.workspace / "passed").exists(), app.history(session)) + + +class BrowserGestureTests(unittest.TestCase): + def test_gestures_and_statement_bodies_in_a_real_browser(self): + if not ( + shutil.which("chromium") + or shutil.which("google-chrome") + or shutil.which("chrome-headless-shell") + or Path( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + ).exists() + or Path("/Applications/Chromium.app/Contents/MacOS/Chromium").exists() + ): + self.skipTest("No Chrome or Chromium installed on this system") + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = b"