Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 52 additions & 18 deletions src/widgetastic/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from typing import Type
from typing import TYPE_CHECKING
from typing import Union
from typing import Literal
import warnings

from cached_property import cached_property
Expand Down Expand Up @@ -846,8 +847,12 @@ def highlight(self, locator: LocatorAlias, *args, **kwargs) -> None:
def click(
self,
locator: LocatorAlias,
button: str = "left",
no_wait_after: bool = False,
button: Optional[Literal["left", "middle", "right"]] = "left",
click_count: Optional[int] = None,
delay: Optional[float] = None,
force: Optional[bool] = None,
no_wait_after: Optional[bool] = None,
timeout: Optional[float] = None,
*args,
**kwargs,
) -> None:
Expand All @@ -856,27 +861,40 @@ def click(
Args:
locator: Element locator to click on
button: Mouse button to click with ("left", "right", or "middle")
click_count: defaults to 1
delay: Time to wait between `mousedown` and `mouseup` in milliseconds. Defaults to 0.
force: Whether to bypass the actionability checks
no_wait_after: If True, don't wait for page events after click
ignore_ajax: If True, expect blocking dialogs (passed via kwargs)
"""
# Validate button parameter
valid_buttons = ["left", "right", "middle"]
if button not in valid_buttons:
raise ValueError(
f"Invalid button '{button}'. Must be one of: {', '.join(valid_buttons)}"
)
timeout: Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout.

Deprecated:
ignore_ajax: Deprecated parameter. Use `no_wait_after=True` instead.
"""
self.logger.debug("click: %r with %s button", locator, button)

# Handle deprecated ignore_ajax parameter
ignore_ajax = kwargs.pop("ignore_ajax", False)
if ignore_ajax:
warnings.warn(
"The 'ignore_ajax' parameter is deprecated and will be removed in a future version. "
"Use 'no_wait_after=True' instead.",
DeprecationWarning,
stacklevel=2,
)
no_wait_after = True

el = self.element(locator, *args, **kwargs)
self.plugin.before_click(el, locator)

# If ignore_ajax is True, it's a signal that a blocking dialog is expected.
# We pass no_wait_after=True to prevent a timeout.
if ignore_ajax or no_wait_after:
el.click(button=button, no_wait_after=True)
else:
el.click(button=button)
el.click(
button=button,
click_count=click_count,
delay=delay,
force=force,
no_wait_after=no_wait_after,
timeout=timeout,
)
if not ignore_ajax and not no_wait_after:
try:
self.plugin.ensure_page_safe()
except TimedOutError:
Expand All @@ -890,13 +908,29 @@ def double_click(self, locator: LocatorAlias, *args, **kwargs) -> None:
locator: Element locator to double-click on
*args: Additional arguments passed to element() method
**kwargs: Additional keyword arguments passed to element() method

Deprecated:
ignore_ajax: Deprecated parameter. Use `no_wait_after=True` instead.
"""
self.logger.debug("double_click: %r", locator)

# Handle deprecated ignore_ajax parameter
ignore_ajax = kwargs.pop("ignore_ajax", False)
if ignore_ajax:
warnings.warn(
"The 'ignore_ajax' parameter is deprecated and will be removed in a future version. "
"Use 'no_wait_after=True' instead.",
DeprecationWarning,
stacklevel=2,
)

no_wait_after = kwargs.pop("no_wait_after", False)

el = self.element(locator, *args, **kwargs)
self.plugin.before_click(el, locator)
el.dblclick()
if not ignore_ajax:
el.dblclick(no_wait_after=no_wait_after)

if not ignore_ajax and not no_wait_after:
try:
self.plugin.ensure_page_safe()
except TimedOutError:
Expand Down
2 changes: 1 addition & 1 deletion src/widgetastic/widget/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ def click(self, handle_alert=None):
Args:
handle_alert: Special alert handling. None - no handling, True - accept, False - dismiss
"""
self.browser.click(self, ignore_ajax=(handle_alert is not None))
self.browser.click(self, no_wait_after=(handle_alert is not None))
if handle_alert is not None:
self.browser.handle_alert(cancel=not handle_alert, wait=2.0, squash=True)

Expand Down
70 changes: 64 additions & 6 deletions testing/html/testing_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,18 @@ <h2 class="foo bar wt-invisible" style="display: none;" id="invisible">This is i

<div class="widget-group">
<div class="widget-title">Interactive Buttons</div>
<div class="widget-description">Buttons with state changes and click handlers</div>
<button id="a_button" onclick="this.className += ' clicked'">Click Me (State Changes)</button>
<div class="code-example">Button adds 'clicked' class when pressed</div>
<div class="widget-description">Buttons with state changes, click handlers, and enhanced click parameters</div>
<button id="a_button" onclick="handleClickCount(event)">Click Me (State Changes)</button>
<div id="click_count_result" style="margin-left: 10px; font-weight: bold; color: #007bff; display: inline;">Clicks: 0</div>
<div class="code-example">Button tracks click count and adds 'clicked' class (tests click_count parameter)</div>

<button id='disabled_button' disabled>Disabled Button</button>
<div class="code-example">Button in disabled state - cannot be clicked</div>
<button id="multi_button" onmousedown="handleButtonClick(event)">Multi-Click Test</button>
<div id="click_result" style="margin-left: 10px; font-weight: bold; color: #007bff;">Ready</div>
<div class="code-example">Button detects left, right, and middle mouse clicks</div>

<button id="multi_button" onmousedown="handleButtonClick(event)" onmouseup="handleMouseUp()">Multi-Click Test</button>
<div id="click_result" style="margin-left: 10px; font-weight: bold; color: #007bff; display: inline;">Ready</div>
<div id="click_delay_result" style="margin-left: 10px; font-weight: bold; color: #007bff; display: inline;">Delay: 0ms</div>
<div class="code-example">Button detects mouse buttons and measures click delay (tests button, delay, force, and timeout parameters)</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -721,6 +725,46 @@ <h2 class="foo bar wt-invisible" style="display: none;" id="invisible">This is i
document.getElementById("alert_out").innerHTML = txt;
}

// Click count tracking for #a_button
var clickCountData = {
count: 0,
lastClick: 0
};

function handleClickCount(event) {
var now = Date.now();
// Reset count if more than 500ms since last click
if (now - clickCountData.lastClick > 500) {
clickCountData.count = 0;
}
clickCountData.count++;
clickCountData.lastClick = now;

document.getElementById("click_count_result").textContent = "Clicks: " + clickCountData.count;

// Add specific class based on click count
if (clickCountData.count === 2) {
event.target.className = event.target.className.replace(/\s*single-clicked/g, '').replace(/\s*clicked/g, '') + ' double-clicked';
} else if (clickCountData.count === 3) {
event.target.className = event.target.className.replace(/\s*double-clicked/g, '') + ' triple-clicked';
} else {
event.target.className = event.target.className.replace(/\s*(double|triple)-clicked/g, '') + ' single-clicked clicked';
}
}

function resetClickCount() {
clickCountData.count = 0;
clickCountData.lastClick = 0;
var btn = document.getElementById("a_button");
if (btn) btn.className = "";
document.getElementById("click_count_result").textContent = "Clicks: 0";
}

// Click delay tracking for #multi_button
var clickDelayData = {
mousedownTime: 0
};

function handleButtonClick(event) {
var result = document.getElementById("click_result");
var clickType = "";
Expand All @@ -740,8 +784,22 @@ <h2 class="foo bar wt-invisible" style="display: none;" id="invisible">This is i
}

result.textContent = clickType;
clickDelayData.mousedownTime = Date.now();
event.preventDefault();
}

function handleMouseUp() {
if (clickDelayData.mousedownTime > 0) {
var delay = Date.now() - clickDelayData.mousedownTime;
document.getElementById("click_delay_result").textContent = "Delay: " + delay + "ms";
clickDelayData.mousedownTime = 0;
}
}

function resetClickDelay() {
clickDelayData.mousedownTime = 0;
document.getElementById("click_delay_result").textContent = "Delay: 0ms";
}
</script>

<!-- Form Elements & Checkboxes Section -->
Expand Down
81 changes: 75 additions & 6 deletions testing/test_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from datetime import datetime
from pathlib import Path

import playwright
import pytest

from widgetastic.browser import BrowserParentWrapper
from widgetastic.exceptions import LocatorNotImplemented
from widgetastic.exceptions import NoSuchElementException
from widgetastic.widget import Text
from widgetastic.widget import View
from playwright.sync_api import Locator


@pytest.fixture()
Expand Down Expand Up @@ -273,7 +273,10 @@ def test_wait_for_element_visible(browser):
# Click on the button
browser.click("#invisible_appear_button")
try:
assert isinstance(browser.wait_for_element("#invisible_appear_p", visible=True), Locator)
assert isinstance(
browser.wait_for_element("#invisible_appear_p", visible=True),
playwright.sync_api.Locator,
)
except NoSuchElementException:
pytest.fail("NoSuchElementException raised when webelement expected")

Expand Down Expand Up @@ -603,7 +606,7 @@ def test_click_with_ignore_ajax(browser):

def test_click_with_invalid_button(browser):
"""Test click method with invalid button parameter raises ValueError."""
with pytest.raises(ValueError, match="Invalid button 'invalid'"):
with pytest.raises(playwright._impl._errors.Error):
browser.click("#a_button", button="invalid")


Expand Down Expand Up @@ -655,19 +658,85 @@ def test_mouse_click(browser, button):
assert result == expected_result


def test_click_with_click_count(browser):
"""Test click method with click_count=<number> parameter."""
for click_count in [1, 2, 3]:
# Reset first
browser.execute_script("resetClickCount()")

browser.click("#a_button", click_count=click_count)

# Verify click count was detected
result = browser.text("#click_count_result")
assert f"Clicks: {click_count}" in result
assert "clicked" in browser.classes("#a_button").pop()


def test_click_with_delay_parameter(browser):
"""Test click method with delay parameter between mousedown and mouseup."""
for delay_ms in [100, 0]:
browser.execute_script("resetClickDelay()")

browser.click("#multi_button", delay=delay_ms)
# Give a moment for the handler to process
time.sleep(0.05)

# Verify delay was applied (allow some tolerance for execution time)
result = browser.text("#click_delay_result")
assert "Delay:" in result

# Extract the delay value from result
delay_value = int(result.split(":")[1].strip().replace("ms", ""))
print(delay_value)
# Allow 10ms tolerance for browser timing variations
assert delay_value >= delay_ms
assert delay_value <= delay_ms + 10


def test_click_with_force_parameter(browser):
"""Test click method with force parameter (True and False)."""
# force parameter bypasses Playwright's actionability checks
# We test that the parameter is properly passed through
browser.execute_script("resetClickCount()")

# Test force=True works
browser.click("#a_button", force=True)
assert "clicked" in browser.classes("#a_button")

browser.refresh()
browser.execute_script("resetClickCount()")

# Test force=False works (default behavior)
browser.click("#a_button", force=False)
assert "clicked" in browser.classes("#a_button")


def test_click_with_timeout_parameter(browser):
"""Test click method with timeout parameter."""
# Test that timeout parameter is accepted and doesn't break normal clicks
browser.click("#a_button", timeout=0)
assert "clicked" in browser.classes("#a_button")

browser.refresh()
browser.execute_script("resetClickCount()")

browser.click("#a_button", timeout=100)
assert "clicked" in browser.classes("#a_button")


def test_double_click_method(browser):
"""Test double_click method."""
initial_classes = browser.classes("#a_button")
browser.double_click("#a_button")
final_classes = browser.classes("#a_button")
assert "clicked" in final_classes
assert "double-clicked" in final_classes
assert "clicked" not in initial_classes


def test_double_click_with_ignore_ajax(browser):
"""Test double_click method with ignore_ajax parameter."""
browser.double_click("#a_button", ignore_ajax=True)
assert "clicked" in browser.classes("#a_button")
assert "double-clicked" in browser.classes("#a_button")


def test_double_click_with_timed_out_error_in_ensure_page_safe(browser, monkeypatch):
Expand Down Expand Up @@ -703,7 +772,7 @@ def mock_after_click_safe_timeout(el, locator):

# Verify the timeout handler was called
assert timeout_called is True
assert "clicked" in browser.classes("#a_button")
assert "double-clicked" in browser.classes("#a_button")


def test_raw_click(browser):
Expand Down