diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index c311269..ea1d10b 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -1815,13 +1815,12 @@ git commit -m "feat: Page element maps with MRO inheritance and typed dispatch" - [ ] **Step 1: Write the failing tests** -Append to `tests/unit/test_page.py`: +Add `import wabot.page as page_mod` to the top-of-file import block and fold +`HumanPacing` into the existing `from wabot.pacing import NoPacing` (keeping the +imports at the top avoids ruff `E402`), then append these test classes to +`tests/unit/test_page.py`: ```python -import wabot.page as page_mod -from wabot.pacing import HumanPacing - - class PlainPage(Page): elements = {} @@ -1921,6 +1920,79 @@ class TestStaleness: page = PlainPage(make_browser()) assert page.is_element_stale(StaleEl()) is True + + +class TestAlert: + def test_accept_alert_accepts_and_returns_text(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) # until() won't raise + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + assert page.accept_alert() == "confirm?" + alert.accept.assert_called_once_with() + + def test_accept_alert_can_dismiss(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + page.accept_alert(accept=False) + alert.dismiss.assert_called_once_with() + + def test_accept_alert_returns_false_when_no_alert(self, monkeypatch): + from selenium.common.exceptions import TimeoutException + + page = PlainPage(make_browser()) + wait = MagicMock() + wait.return_value.until.side_effect = TimeoutException() + monkeypatch.setattr(page_mod, "WebDriverWait", wait) + assert page.accept_alert(timeout=0) is False + + +class TestSelectHelpers: + def test_get_select_value(self): + select = MagicMock() + select.first_selected_option.get_attribute.return_value = "CO" + assert PlainPage(make_browser()).get_select_value(select) == "CO" + + def test_get_select_value_null_returns_none(self): + assert PlainPage(make_browser()).get_select_value(None) is None + + def test_set_select_value_by_value_including_zero(self): + # legacy `if value:` skipped a valid 0/"" — verify the fix + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, value=0) is True + select.select_by_value.assert_called_once_with("0") + + def test_set_select_value_by_text(self): + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, text="Colorado") is True + select.select_by_visible_text.assert_called_once_with("Colorado") + + +class TestCheckboxAndCoercion: + def test_set_checkbox_returns_false_when_click_fails(self, monkeypatch): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = True + el.is_selected.return_value = False # needs toggling + monkeypatch.setattr(page, "click", lambda e: False) # click fails + assert page.set_checkbox(el, True) is False + + def test_set_checkbox_disabled_returns_false(self): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = False + assert page.set_checkbox(el, True) is False + + def test_set_el_value_coerces_non_str(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "42" + assert page.set_el_value(el, 42) is True + el.send_keys.assert_called_once_with("42") ``` - [ ] **Step 2: Run tests to verify they fail** @@ -1965,13 +2037,13 @@ Append these methods to `class Page`: if not el: LOGGER.warning("refusing to click null element") return False + if not self._wait_clickable(el, clickable_timeout): + return False try: size = el.size except StaleElementReferenceException: LOGGER.error("failed to click element: stale reference") return False - if not self._wait_clickable(el, clickable_timeout): - return False offset = self.pacing.click_offset(size["width"], size["height"]) try: if offset is None: @@ -2067,11 +2139,13 @@ Append these methods to `class Page`: el.send_keys(char) time.sleep(self.pacing.delay("text") / 10.0) else: - el.send_keys(value) + el.send_keys(str(value)) except WebDriverException as ex: LOGGER.error("failed to send keys, element in unknown state: %s", ex) return False actual = self.get_el_value(el) + # verify the field holds exactly what we typed (strict: fields that + # normalize input will read back as a mismatch) if str(actual) != str(value): LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual) return False @@ -2106,7 +2180,7 @@ Append these methods to `class Page`: if not el.is_enabled(): return False if el.is_selected() != checked: - self.click(el) + return self.click(el) # propagate: a failed click is a failed set return True def get_checkbox_value(self, el, ignore_disabled: bool = False): @@ -2135,7 +2209,7 @@ Append these methods to `class Page`: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_page.py -v` -Expected: 26 passed (15 from Task 8 + 11 new) +Expected: 36 passed (15 from Task 8 + 21 new) - [ ] **Step 5: Commit** @@ -2875,7 +2949,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (116 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 26, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (126 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/page.py b/src/wabot/page.py index 51b89ea..d286ef1 100644 --- a/src/wabot/page.py +++ b/src/wabot/page.py @@ -113,13 +113,13 @@ class Page: if not el: LOGGER.warning("refusing to click null element") return False + if not self._wait_clickable(el, clickable_timeout): + return False try: size = el.size except StaleElementReferenceException: LOGGER.error("failed to click element: stale reference") return False - if not self._wait_clickable(el, clickable_timeout): - return False offset = self.pacing.click_offset(size["width"], size["height"]) try: if offset is None: @@ -215,11 +215,13 @@ class Page: el.send_keys(char) time.sleep(self.pacing.delay("text") / 10.0) else: - el.send_keys(value) + el.send_keys(str(value)) except WebDriverException as ex: LOGGER.error("failed to send keys, element in unknown state: %s", ex) return False actual = self.get_el_value(el) + # verify the field holds exactly what we typed (strict: fields that + # normalize input will read back as a mismatch) if str(actual) != str(value): LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual) return False @@ -254,7 +256,7 @@ class Page: if not el.is_enabled(): return False if el.is_selected() != checked: - self.click(el) + return self.click(el) # propagate: a failed click is a failed set return True def get_checkbox_value(self, el, ignore_disabled: bool = False): diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py index c26b4ca..99823aa 100644 --- a/tests/unit/test_page.py +++ b/tests/unit/test_page.py @@ -223,3 +223,76 @@ class TestStaleness: page = PlainPage(make_browser()) assert page.is_element_stale(StaleEl()) is True + + +class TestAlert: + def test_accept_alert_accepts_and_returns_text(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) # until() won't raise + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + assert page.accept_alert() == "confirm?" + alert.accept.assert_called_once_with() + + def test_accept_alert_can_dismiss(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + page.accept_alert(accept=False) + alert.dismiss.assert_called_once_with() + + def test_accept_alert_returns_false_when_no_alert(self, monkeypatch): + from selenium.common.exceptions import TimeoutException + + page = PlainPage(make_browser()) + wait = MagicMock() + wait.return_value.until.side_effect = TimeoutException() + monkeypatch.setattr(page_mod, "WebDriverWait", wait) + assert page.accept_alert(timeout=0) is False + + +class TestSelectHelpers: + def test_get_select_value(self): + select = MagicMock() + select.first_selected_option.get_attribute.return_value = "CO" + assert PlainPage(make_browser()).get_select_value(select) == "CO" + + def test_get_select_value_null_returns_none(self): + assert PlainPage(make_browser()).get_select_value(None) is None + + def test_set_select_value_by_value_including_zero(self): + # legacy `if value:` skipped a valid 0/"" — verify the fix + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, value=0) is True + select.select_by_value.assert_called_once_with("0") + + def test_set_select_value_by_text(self): + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, text="Colorado") is True + select.select_by_visible_text.assert_called_once_with("Colorado") + + +class TestCheckboxAndCoercion: + def test_set_checkbox_returns_false_when_click_fails(self, monkeypatch): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = True + el.is_selected.return_value = False # needs toggling + monkeypatch.setattr(page, "click", lambda e: False) # click fails + assert page.set_checkbox(el, True) is False + + def test_set_checkbox_disabled_returns_false(self): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = False + assert page.set_checkbox(el, True) is False + + def test_set_el_value_coerces_non_str(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "42" + assert page.set_el_value(el, 42) is True + el.send_keys.assert_called_once_with("42")