From d176ee883cfb78a771e0c4e8dcf62ddca597b3b2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 20:45:59 -0500 Subject: [PATCH 1/2] fix(flow): 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. type_text and type_text_guarded now set an ISO date into a native date input with fill, which takes the ISO value whatever the locale and fires input and change. The guarded path fills the leased element itself, and a failed fill raises (ActionDeliveryUncertain) instead of falling back to typing. Other text and other fields are typed as before. Salvaged from #1548 (second installation, Phase A). --- .../backends/playwright_backend.py | 47 +++++++++- .../flow/tests/test_playwright_date_input.py | 85 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 packages/flow/tests/test_playwright_date_input.py diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py index ea9e2c99df..f72a4026dc 100644 --- a/packages/flow/openadapt_flow/backends/playwright_backend.py +++ b/packages/flow/openadapt_flow/backends/playwright_backend.py @@ -1059,6 +1059,33 @@ class _FramePoint: return ['text', 'email', 'tel', 'search', 'url'].includes(t); }""" +#: An ISO calendar date, the value a native ```` holds. +_ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") +_IS_DATE_INPUT_JS = "e => e.tagName === 'INPUT' && e.type === 'date'" +_FOCUSED_DATE_INPUT_JS = ( + "() => { const e = document.activeElement;" + " return !!e && e.tagName === 'INPUT' && e.type === 'date'; }" +) + + +def _fill_iso_date(locator: Any, text: str, is_date_input: Callable[[], bool]) -> 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`` as typing does. Returns ``False``, with + nothing delivered, when ``text`` isn't an ISO date or the element isn't a + date input; once it is, a failed ``fill`` raises rather than falling back + to typing. + """ + + if not (_ISO_DATE.fullmatch(text or "") and is_date_input()): + return False + locator.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 +4234,14 @@ 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: ( + _fill_iso_date( + locator, + text, + lambda: locator.evaluate(_IS_DATE_INPUT_JS) is True, + ) + or locator.press_sequentially(text, timeout=1000) + ), ) self._snapshot_focused_field(text) return receipt @@ -4770,11 +4804,20 @@ def _focused_plain_text_field(self) -> bool: except Exception: return False + def _focused_date_input(self) -> bool: + try: + return self.page.evaluate(_FOCUSED_DATE_INPUT_JS) is True + except Exception: + return False + 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 _fill_iso_date(self.page.locator(":focus"), text, self._focused_date_input): + 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..66ab3edc37 --- /dev/null +++ b/packages/flow/tests/test_playwright_date_input.py @@ -0,0 +1,85 @@ +"""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.format(identity="a" * 64)) + 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 From 19540f784a966e6544531f1f12462912caa6bbce Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 20:55:31 -0500 Subject: [PATCH 2/2] fix(flow): date fill only for a real ASCII ISO date into an editable date input, through frames and shadow roots Review findings on #1806: fill clears a date input before rejecting a malformed value, so 2001-02-30 and non-ASCII digits are typed as before and never reach fill; readonly and disabled date inputs are typed as before; the guarded check and fill run on one element handle with a 1 s timeout; the unguarded path finds the focused date input inside frames and open shadow roots, and checks and fills that same handle. --- .../backends/playwright_backend.py | 84 ++++++++++++++----- .../flow/tests/test_playwright_date_input.py | 74 +++++++++++++++- 2 files changed, 133 insertions(+), 25 deletions(-) diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py index f72a4026dc..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 @@ -1060,29 +1060,51 @@ class _FramePoint: }""" #: An ISO calendar date, the value a native ```` holds. -_ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") -_IS_DATE_INPUT_JS = "e => e.tagName === 'INPUT' && e.type === 'date'" -_FOCUSED_DATE_INPUT_JS = ( - "() => { const e = document.activeElement;" - " return !!e && e.tagName === 'INPUT' && e.type === 'date'; }" +_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.""" -def _fill_iso_date(locator: Any, text: str, is_date_input: Callable[[], bool]) -> bool: + 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`` as typing does. Returns ``False``, with - nothing delivered, when ``text`` isn't an ISO date or the element isn't a - date input; once it is, a failed ``fill`` raises rather than falling back - to typing. + 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 (_ISO_DATE.fullmatch(text or "") and is_date_input()): + if not _is_iso_date(text): return False - locator.fill(text, timeout=1000) + if target.evaluate(_IS_DATE_INPUT_JS) is not True: + return False + target.fill(text, timeout=1000) return True @@ -4235,10 +4257,9 @@ def type_text_guarded( # Keys go through the leased element itself, never the page # keyboard, so delivery stays bound to the checked target. deliver=lambda locator: ( - _fill_iso_date( - locator, - text, - lambda: locator.evaluate(_IS_DATE_INPUT_JS) is True, + ( + _is_iso_date(text) + and _fill_iso_date(locator.element_handle(timeout=1000), text) ) or locator.press_sequentially(text, timeout=1000) ), @@ -4804,11 +4825,30 @@ def _focused_plain_text_field(self) -> bool: except Exception: return False - def _focused_date_input(self) -> bool: + 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: - return self.page.evaluate(_FOCUSED_DATE_INPUT_JS) is True - except Exception: + 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 @@ -4816,7 +4856,7 @@ def _deliver_text(self, text: str, per_key: Callable[[], None]) -> None: fires, and ``change`` on blur, as for typing); otherwise ``per_key`` types it key by key. An ISO date into a native date input is set with ``fill`` (see :func:`_fill_iso_date`).""" - if _fill_iso_date(self.page.locator(":focus"), text, self._focused_date_input): + 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) diff --git a/packages/flow/tests/test_playwright_date_input.py b/packages/flow/tests/test_playwright_date_input.py index 66ab3edc37..8c59cf5d82 100644 --- a/packages/flow/tests/test_playwright_date_input.py +++ b/packages/flow/tests/test_playwright_date_input.py @@ -15,10 +15,16 @@
Birth date + +
+ +
""" @@ -33,7 +39,8 @@ def page_backend(): 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.format(identity="a" * 64)) + page.set_content(_PAGE.replace("{identity}", "a" * 64)) + page.frame_locator("#f").locator("#fd").wait_for() yield PlaywrightBackend(page), page browser.close() @@ -60,7 +67,7 @@ def test_an_iso_date_lands_in_a_date_input(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 + assert page.evaluate("window.changes") == 1 def test_other_text_and_other_fields_are_typed(page_backend): @@ -82,4 +89,65 @@ def test_an_iso_date_lands_through_the_guarded_lease(page_backend): 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 + 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"