Skip to content

Commit 3bee93a

Browse files
committed
expect new page and fix js windows handling
1 parent 4167850 commit 3bee93a

File tree

2 files changed

+292
-12
lines changed

2 files changed

+292
-12
lines changed

src/widgetastic/browser.py

Lines changed: 209 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@
3636
from playwright.sync_api import Error as PlaywrightError
3737
from playwright.sync_api import Locator
3838
from playwright.sync_api import Page
39+
from playwright.sync_api import TimeoutError
3940

4041
from .locator import SmartLocator
41-
from wait_for import TimedOutError
42+
from wait_for import TimedOutError, wait_for
4243

4344
from .exceptions import LocatorNotImplemented
4445
from .exceptions import NoSuchElementException
@@ -1628,6 +1629,119 @@ def _on_new_page(self, page: Page):
16281629
self.current.logger.info("New page opened / popup detected: %s", page.url)
16291630
self._wrap_page(page)
16301631

1632+
def expect_new_page(self, timeout: float = 5.0):
1633+
"""Context manager to wait for a new page and return the wrapped Browser instance.
1634+
1635+
This method uses Playwright's native `expect_page()` to wait for a new page
1636+
to be created. Use this when you know an action (like clicking a link with
1637+
target="_blank" or a button that calls window.open()) will open a new page.
1638+
1639+
The context manager automatically waits for the page to load (domcontentloaded).
1640+
1641+
Args:
1642+
timeout: Maximum time to wait for the new page in seconds (default: 5.0)
1643+
1644+
Yields:
1645+
Browser: The wrapped Browser instance for the new page
1646+
1647+
Example:
1648+
.. code-block:: python
1649+
1650+
# Get Browser directly - returns actual Browser instance
1651+
with window_manager.expect_new_page() as new_browser:
1652+
browser.click("a[target='_blank']")
1653+
# new_browser is the wrapped Browser instance, ready to use
1654+
assert new_browser.title == "New Page"
1655+
new_browser.element("#submit").click()
1656+
window_manager.close_browser(new_browser)
1657+
1658+
# Or with a longer timeout
1659+
with window_manager.expect_new_page(timeout=10.0) as popup_browser:
1660+
browser.click("#open-popup-button")
1661+
popup_browser.element("#confirm").click()
1662+
"""
1663+
1664+
class _BrowserWaiter:
1665+
"""Simple context manager that waits for page and returns wrapped Browser."""
1666+
1667+
def __init__(self, window_manager: "WindowManager", timeout_ms: int) -> None:
1668+
self.window_manager = window_manager
1669+
self.context = window_manager._context
1670+
self.timeout_ms = timeout_ms
1671+
self.page_info: Optional[Any] = None
1672+
self._browser: Optional[Browser] = None
1673+
self._initial_browsers: Optional[Set[Browser]] = None
1674+
1675+
def __enter__(self) -> "_BrowserWaiter":
1676+
# Capture initial browsers before setting up expectation
1677+
self._initial_browsers = set(self.window_manager.all_browsers)
1678+
self.page_info = self.context.expect_page(timeout=self.timeout_ms)
1679+
self.page_info.__enter__()
1680+
return self
1681+
1682+
def __exit__(self, *args: Any) -> None:
1683+
# Exit Playwright's context manager to get the Page/Browser
1684+
if not self.page_info:
1685+
# If page_info was never set, something went wrong in __enter__
1686+
raise RuntimeError(
1687+
"expect_new_page context manager was not properly initialized. "
1688+
"This should not happen."
1689+
)
1690+
1691+
new_page = None
1692+
self.page_info.__exit__(*args)
1693+
try:
1694+
new_page = self.page_info.value
1695+
except AttributeError:
1696+
# value might not be available in some edge cases
1697+
pass
1698+
1699+
if new_page:
1700+
# Wait for page to load and wrap it
1701+
try:
1702+
new_page.wait_for_load_state("domcontentloaded", timeout=5000)
1703+
except TimeoutError:
1704+
self.logger.warning(
1705+
"Timed out waiting for new page to load (domcontentloaded)."
1706+
)
1707+
self._browser = self.window_manager._wrap_page(new_page)
1708+
else:
1709+
# expect_page succeeded but value is None.This can happen if the event handler already wrapped the page
1710+
# Try to find it by comparing browser sets
1711+
if self._initial_browsers is None:
1712+
raise RuntimeError(
1713+
"expect_new_page context manager was not properly initialized. "
1714+
"This should not happen."
1715+
)
1716+
current_browsers = set(self.window_manager.all_browsers)
1717+
1718+
if new_browsers := current_browsers - self._initial_browsers:
1719+
self._browser = next(iter(new_browsers))
1720+
else:
1721+
# No new page found - this shouldn't happen if expect_page succeeded
1722+
raise RuntimeError(
1723+
"expect_page succeeded but no new page was found. "
1724+
"This may indicate a timing issue or the page was closed immediately."
1725+
)
1726+
1727+
def __getattr__(self, name: str) -> Any:
1728+
"""Delegate all attribute access to the wrapped Browser."""
1729+
if self._browser is None:
1730+
raise RuntimeError(
1731+
"Browser is not available yet. Access it after the 'with' block exits."
1732+
)
1733+
return getattr(self._browser, name)
1734+
1735+
def __eq__(self, other: Any) -> bool:
1736+
"""Compare by page identity for 'in' checks to work."""
1737+
if self._browser is None:
1738+
return False
1739+
if isinstance(other, Browser):
1740+
return self._browser.page is other.page
1741+
return False
1742+
1743+
return _BrowserWaiter(self, int(timeout * 1000))
1744+
16311745
@property
16321746
def all_browsers(self) -> List[Browser]:
16331747
"""Get all managed Browser instances with automatic cleanup.
@@ -1636,6 +1750,12 @@ def all_browsers(self) -> List[Browser]:
16361750
automatically performs cleanup by removing any Browser instances associated with
16371751
closed pages, ensuring the returned list only contains valid, active browsers.
16381752
1753+
Note:
1754+
When you know a click will open a new page, use ``expect_new_page()`` context manager
1755+
for reliable detection.
1756+
1757+
Returns:
1758+
List[Browser]: A list of all currently active widgetastic Browser instances
16391759
16401760
Example:
16411761
.. code-block:: python
@@ -1647,25 +1767,82 @@ def all_browsers(self) -> List[Browser]:
16471767
# Iterate through all browsers
16481768
for i, browser in enumerate(browsers):
16491769
print(f"Window {i}: {browser.title} - {browser.url}")
1770+
1771+
# For reliable new page detection, use expect_new_page
1772+
with window_manager.expect_new_page() as new_browser:
1773+
browser.click("a[target='_blank']")
1774+
# New page is now available in all_browsers
16501775
"""
1651-
current_pages = self._context.pages
16521776
# Clean up browsers for pages that are no longer in the context or are closed
16531777
for page in list(self._browsers.keys()):
16541778
try:
16551779
# Check if page is still in context and not closed
1656-
if page not in current_pages or page.is_closed():
1780+
if page.is_closed():
16571781
del self._browsers[page]
16581782
except Exception:
1659-
# If we can't check the page state, assume it's closed and remove it
1783+
# If we can't check the page state, assume it's closed
16601784
if page in self._browsers:
16611785
del self._browsers[page]
16621786

16631787
# Ensure all current pages are wrapped
1664-
for page in current_pages:
1665-
if not page.is_closed():
1788+
for page in self._context.pages:
1789+
if not page.is_closed() and page not in self._browsers:
1790+
self._wrap_page(page)
1791+
1792+
# Quick check for new pages that might be in transition
1793+
initial_count = len([p for p in self._context.pages if not p.is_closed()])
1794+
initial_wrapped_count = len([p for p in self._browsers.keys() if not p.is_closed()])
1795+
1796+
def _check_for_new_pages():
1797+
"""Check if new pages have appeared."""
1798+
current_pages = [p for p in self._context.pages if not p.is_closed()]
1799+
current_wrapped = [p for p in self._browsers.keys() if not p.is_closed()]
1800+
1801+
# Wrap any new pages found
1802+
for page in current_pages:
1803+
if page not in self._browsers:
1804+
self._wrap_page(page)
1805+
1806+
# Check if we detected a new page
1807+
return (
1808+
len(current_pages) > initial_count # New page in context.pages
1809+
or len(current_wrapped) > initial_wrapped_count # Event handler wrapped a new page
1810+
)
1811+
1812+
# Use wait_for with a short timeout for best-effort detection
1813+
# Note: Tests should use expect_new_page() for reliable detection
1814+
try:
1815+
wait_for(
1816+
_check_for_new_pages,
1817+
num_sec=1.0, # Short timeout - expect_new_page() should be used for reliability
1818+
delay=0.05,
1819+
message="Checking for new pages",
1820+
silent_failure=True,
1821+
very_quiet=True,
1822+
)
1823+
except TimedOutError:
1824+
pass
1825+
1826+
# Final wrap - ensure all pages in context are wrapped
1827+
for page in self._context.pages:
1828+
if not page.is_closed() and page not in self._browsers:
16661829
self._wrap_page(page)
16671830

1668-
return list(self._browsers.values())
1831+
# Return all active browsers
1832+
active_browsers = []
1833+
for page, browser in self._browsers.items():
1834+
try:
1835+
if not page.is_closed() and not browser.is_browser_closed:
1836+
active_browsers.append(browser)
1837+
except Exception:
1838+
# If we can't check page state, try browser state
1839+
try:
1840+
if not browser.is_browser_closed:
1841+
active_browsers.append(browser)
1842+
except Exception:
1843+
active_browsers.append(browser)
1844+
1845+
return active_browsers
16691846

16701847
@property
16711848
def all_pages(self) -> List[Page]:
@@ -1737,7 +1914,7 @@ def switch_to(self, browser_or_page: Union[Browser, Page]):
17371914
current browser for subsequent operations.
17381915
17391916
Args:
1740-
browser_or_page: Browser instance or Playwright Page to switch to
1917+
browser_or_page: Browser instance or Playwright Page to switch to.
17411918
17421919
Raises:
17431920
NoSuchElementException: If the specified page doesn't exist in the context
@@ -1753,12 +1930,23 @@ def switch_to(self, browser_or_page: Union[Browser, Page]):
17531930
all_pages = window_manager.all_pages
17541931
window_manager.switch_to(all_pages[0])
17551932
1933+
# Switch using browser from expect_new_page
1934+
with window_manager.expect_new_page() as new_browser:
1935+
browser.click("a[target='_blank']")
1936+
window_manager.switch_to(new_browser)
1937+
17561938
# Verify the switch
17571939
print(f"Now on: {window_manager.current.title}")
17581940
"""
1759-
target_page = (
1760-
browser_or_page.page if isinstance(browser_or_page, Browser) else browser_or_page
1761-
)
1941+
if isinstance(browser_or_page, Page):
1942+
target_page = browser_or_page
1943+
elif hasattr(browser_or_page, "page"):
1944+
# This works for Browser and Browser-like proxies (like _BrowserWaiter)
1945+
target_page = browser_or_page.page
1946+
else:
1947+
raise TypeError(
1948+
f"Expected a Browser, Page, or Browser-like object, got {type(browser_or_page)}"
1949+
)
17621950

17631951
if target_page not in self.all_pages:
17641952
raise NoSuchElementException("The specified Page handle does not exist.")
@@ -1801,7 +1989,16 @@ def close_browser(self, browser_or_page: Optional[Union[Browser, Page]] = None):
18011989
print(f"{remaining} tabs still open")
18021990
"""
18031991
target_browser = browser_or_page or self.current
1804-
target_page = target_browser.page if isinstance(target_browser, Browser) else target_browser
1992+
1993+
if isinstance(target_browser, Page):
1994+
target_page = target_browser
1995+
elif hasattr(target_browser, "page"):
1996+
# This works for Browser and Browser-like proxies (like _BrowserWaiter)
1997+
target_page = target_browser.page
1998+
else:
1999+
raise TypeError(
2000+
f"Expected a Browser, Page, or Browser-like object, got {type(target_browser)}"
2001+
)
18052002

18062003
# Check if page is already closed
18072004
try:

testing/test_window_manager.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,86 @@ def test_browser_workflow_integration(isolated_window_manager, external_test_url
360360
# Verify current browser is still valid
361361
assert isolated_window_manager.current in isolated_window_manager.all_browsers
362362
assert not isolated_window_manager.current.is_browser_closed
363+
364+
365+
@pytest.mark.parametrize(
366+
"widget_name,expected_title",
367+
[
368+
("open_popup_button", "Widgetastic.Core - Testing Page"),
369+
("open_tab_button", "Widgetastic.Core - Testing Page"),
370+
("external_link", "External Test Page"),
371+
],
372+
ids=["popup", "tab", "link"],
373+
)
374+
def test_js_window_open_detection(
375+
isolated_window_manager,
376+
popup_test_page_url,
377+
widget_name,
378+
expected_title,
379+
):
380+
"""Test that new tabs/popups opened via JavaScript or links are automatically detected.
381+
382+
This test verifies that WindowManager correctly detects new pages opened through:
383+
- JavaScript window.open() for popup windows
384+
- JavaScript window.open() for new tabs
385+
- HTML anchor tags with target="_blank"
386+
"""
387+
from widgetastic.widget import View, Text
388+
389+
class PopupPageView(View):
390+
"""View for popup_test_page.html"""
391+
392+
open_popup_button = Text("#open-popup")
393+
open_tab_button = Text("#open-new-tab")
394+
external_link = Text("#external-link")
395+
396+
# Navigate to popup test page
397+
initial_browser = isolated_window_manager.current
398+
isolated_window_manager.current.url = popup_test_page_url
399+
popup_view = PopupPageView(initial_browser)
400+
401+
initial_count = len(isolated_window_manager.all_browsers)
402+
assert initial_count >= 1, "Should have at least one browser initially"
403+
404+
# Open new tab/popup via JavaScript or link click
405+
widget = getattr(popup_view, widget_name)
406+
with isolated_window_manager.expect_new_page(timeout=5.0) as new_browser:
407+
widget.click()
408+
409+
assert new_browser is not None, f"New browser should be returned for {widget_name}"
410+
assert not new_browser.is_browser_closed, "New browser should be active"
411+
assert new_browser.url != popup_test_page_url, (
412+
f"New browser should have different URL than the original page for {widget_name}"
413+
)
414+
415+
# Verify the page title matches expected value
416+
actual_title = new_browser.text(".//h1")
417+
assert actual_title == expected_title, (
418+
f"Expected title '{expected_title}' but got '{actual_title}' "
419+
f"for page opened by {widget_name}"
420+
)
421+
422+
# Verify it's in all_browsers
423+
browsers_after = isolated_window_manager.all_browsers
424+
assert len(browsers_after) == initial_count + 1, (
425+
f"New page opened by {widget_name} should be detected. "
426+
f"Expected {initial_count + 1} browsers, got {len(browsers_after)}"
427+
)
428+
assert new_browser in browsers_after, f"New browser should be in all_browsers for {widget_name}"
429+
430+
isolated_window_manager.close_browser(new_browser)
431+
isolated_window_manager.switch_to(initial_browser)
432+
433+
434+
def test_expect_new_page_timeout_no_action(
435+
isolated_window_manager,
436+
popup_test_page_url,
437+
):
438+
"""Test that expect_new_page times out when no action is taken to open a new page."""
439+
from playwright.sync_api import TimeoutError
440+
441+
isolated_window_manager.current.url = popup_test_page_url
442+
443+
with pytest.raises(TimeoutError):
444+
with isolated_window_manager.expect_new_page(timeout=1.0):
445+
pass

0 commit comments

Comments
 (0)