diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py index ea9e2c99df..90e629b093 100644 --- a/packages/flow/openadapt_flow/backends/playwright_backend.py +++ b/packages/flow/openadapt_flow/backends/playwright_backend.py @@ -17,7 +17,7 @@ import time import uuid from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import date, datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Literal, Optional from urllib.parse import urlsplit @@ -1059,6 +1059,55 @@ class _FramePoint: return ['text', 'email', 'tel', 'search', 'url'].includes(t); }""" +#: An ISO calendar date, the value a native ```` holds. +_ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") +#: Is the element an editable native date input? +_IS_DATE_INPUT_JS = ( + "e => e.tagName === 'INPUT' && e.type === 'date' && !e.readOnly && !e.disabled" +) +#: The focused element, through open shadow roots (frames are walked in Python). +_ACTIVE_ELEMENT_JS = """() => { + let e = document.activeElement; + while (e && e.shadowRoot && e.shadowRoot.activeElement) { + e = e.shadowRoot.activeElement; + } + return e; +}""" + + +def _is_iso_date(text: str) -> bool: + """True for a real calendar date written ``YYYY-MM-DD`` in ASCII digits.""" + + if not _ISO_DATE.fullmatch(text or ""): + return False + try: + date.fromisoformat(text) + except ValueError: + return False + return True + + +def _fill_iso_date(target: Any, text: str) -> bool: + """Set an ISO date (``YYYY-MM-DD``) into a native ````. + + Chromium splits a date input into locale-ordered segments that a typed + string can't fill: ``-`` doesn't move to the next segment, and the year + segment takes six digits. ``fill`` takes the ISO value whatever the + locale and fires ``input`` and ``change`` (no key events). ``target`` is + an element handle; the check and the fill run on that one element. + Returns ``False``, with nothing delivered, when ``text`` isn't a real ISO + date or the target isn't an editable date input. Once it is, a failed + ``fill`` raises rather than falling back to typing. + """ + + if not _is_iso_date(text): + return False + if target.evaluate(_IS_DATE_INPUT_JS) is not True: + return False + target.fill(text, timeout=1000) + return True + + _TEXT_AT_POINT_JS = r"""([px, py, target]) => { // Recorded texts come from OCR, which drops spaces and misreads // punctuation ("October-2026-" for "October 2026"): keep letters and @@ -4207,7 +4256,13 @@ def type_text_guarded( operation="guarded_dom_type", # Keys go through the leased element itself, never the page # keyboard, so delivery stays bound to the checked target. - deliver=lambda locator: locator.press_sequentially(text, timeout=1000), + deliver=lambda locator: ( + ( + _is_iso_date(text) + and _fill_iso_date(locator.element_handle(timeout=1000), text) + ) + or locator.press_sequentially(text, timeout=1000) + ), ) self._snapshot_focused_field(text) return receipt @@ -4770,11 +4825,39 @@ def _focused_plain_text_field(self) -> bool: except Exception: return False + def _focused_element_handle(self) -> Any: + """The focused element's handle, through frames and open shadow + roots, or None.""" + + scope: Any = self.page + for _depth in range(9): + handle = scope.evaluate_handle(_ACTIVE_ELEMENT_JS).as_element() + if handle is None: + return None + if handle.evaluate("e => e.localName") not in {"iframe", "frame"}: + return handle + scope = handle.content_frame() + if scope is None: + return None + return None + + def _fill_focused_iso_date(self, text: str) -> bool: + if not _is_iso_date(text): + return False + try: + target = self._focused_element_handle() + except Exception: # noqa: BLE001 - unreadable focus: type as before + return False + return target is not None and _fill_iso_date(target, text) + def _deliver_text(self, text: str, per_key: Callable[[], None]) -> None: """Insert ``text`` at the caret in one call when the focused element is an ordinary text field (native fill: the field's ``input`` event fires, and ``change`` on blur, as for typing); otherwise ``per_key`` - types it key by key.""" + types it key by key. An ISO date into a native date input is set + with ``fill`` (see :func:`_fill_iso_date`).""" + if self._fill_focused_iso_date(text): + return if self.native_fill and text and self._focused_plain_text_field(): self.page.keyboard.insert_text(text) return diff --git a/packages/flow/tests/test_playwright_date_input.py b/packages/flow/tests/test_playwright_date_input.py new file mode 100644 index 0000000000..8c59cf5d82 --- /dev/null +++ b/packages/flow/tests/test_playwright_date_input.py @@ -0,0 +1,153 @@ +"""An ISO date typed into a native date input lands as that date. + +Chromium splits ```` into locale-ordered segments, so a +typed ``1984-03-07`` doesn't land: ``-`` doesn't move to the next segment. +The backend sets an ISO date with ``fill``, which takes the ISO value +whatever the locale. Other text, and text into other fields, is typed as +before. Runs headless against a local page. +""" + +from __future__ import annotations + +import pytest + +_PAGE = """ +
Birth date + + + + +
+ +
+""" + + +@pytest.fixture +def page_backend(): + sync = pytest.importorskip("playwright.sync_api") + from openadapt_flow.backends.playwright_backend import PlaywrightBackend + + with sync.sync_playwright() as playwright: + try: + browser = playwright.chromium.launch(headless=True) + except Exception as exc: # noqa: BLE001 - no browser on this machine + pytest.skip(f"chromium unavailable: {exc}") + page = browser.new_page(viewport={"width": 800, "height": 400}) + page.set_content(_PAGE.replace("{identity}", "a" * 64)) + page.frame_locator("#f").locator("#fd").wait_for() + yield PlaywrightBackend(page), page + browser.close() + + +def _center(locator) -> tuple[int, int]: + box = locator.bounding_box() + assert box is not None + return ( + int(round(box["x"] + box["width"] / 2)), + int(round(box["y"] + box["height"] / 2)), + ) + + +def test_typing_an_iso_date_key_by_key_does_not_land(page_backend): + # The reason for the fill: plain key-by-key typing leaves the date empty. + _backend, page = page_backend + page.focus("#d") + page.keyboard.type("1984-03-07") + assert page.input_value("#d") != "1984-03-07" + + +def test_an_iso_date_lands_in_a_date_input(page_backend): + backend, page = page_backend + page.focus("#d") + backend.type_text("1984-03-07") + assert page.input_value("#d") == "1984-03-07" + assert page.evaluate("window.changes") == 1 + + +def test_other_text_and_other_fields_are_typed(page_backend): + backend, page = page_backend + page.focus("#t") + backend.type_text("1984-03-07") + assert page.input_value("#t") == "1984-03-07" + page.focus("#d") + backend.type_text("not a date") + assert page.input_value("#d") == "" + assert page.evaluate("window.changes") == 0 + + +def test_an_iso_date_lands_through_the_guarded_lease(page_backend): + backend, page = page_backend + field = page.locator("#d") + field.focus() + backend.arm_guarded_keyboard(*_center(field)) + receipt = backend.type_text_guarded("2001-12-31", expected_frame_sha256="0" * 64) + assert receipt.operation == "guarded_dom_type" + assert field.input_value() == "2001-12-31" + assert page.evaluate("window.changes") == 1 + + +def test_guarded_non_date_text_is_typed_into_the_lease(page_backend): + backend, page = page_backend + field = page.locator("#t") + field.focus() + backend.arm_guarded_keyboard(*_center(field)) + backend.type_text_guarded("2001-12-31", expected_frame_sha256="0" * 64) + assert field.input_value() == "2001-12-31" + + +class _Target: + def __init__(self) -> None: + self.calls: list[str] = [] + + def evaluate(self, *_args, **_kwargs): + self.calls.append("evaluate") + return True + + def fill(self, *_args, **_kwargs): + self.calls.append("fill") + + +@pytest.mark.parametrize( + "text", ["2001-02-30", "\u0661\u0669\u0668\u0664-03-07", "84-03-07", ""] +) +def test_a_text_that_is_not_a_real_ascii_date_never_touches_the_field(text): + # fill on a date input clears it before rejecting a malformed value, so + # such text must not reach fill (or even the date-input check). + from openadapt_flow.backends.playwright_backend import _fill_iso_date + + target = _Target() + assert _fill_iso_date(target, text) is False + assert target.calls == [] + + +def test_a_real_iso_date_is_checked_then_filled_once(): + from openadapt_flow.backends.playwright_backend import _fill_iso_date + + target = _Target() + assert _fill_iso_date(target, "2024-02-29") is True + assert target.calls == ["evaluate", "fill"] + + +def test_a_readonly_date_input_is_typed_as_before(page_backend): + backend, page = page_backend + page.focus("#ro") + backend.type_text("2001-12-31") + assert page.input_value("#ro") == "1999-01-01" + + +def test_a_date_in_a_frame_or_shadow_root_lands(page_backend): + backend, page = page_backend + inner = page.frame_locator("#f").locator("#fd") + inner.focus() + backend.type_text("2001-12-31") + assert inner.input_value() == "2001-12-31" + shadow = page.locator("#host #sd") + shadow.focus() + backend.type_text("1984-03-07") + assert shadow.input_value() == "1984-03-07"