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 | + + + + + |