Skip to content

Expect new page and fix js windows handling#293

Merged
mshriver merged 1 commit intoRedHatQE:mainfrom
digitronik:js_windows_hanlding
Nov 6, 2025
Merged

Expect new page and fix js windows handling#293
mshriver merged 1 commit intoRedHatQE:mainfrom
digitronik:js_windows_hanlding

Conversation

@digitronik
Copy link
Member

@digitronik digitronik commented Nov 6, 2025

Summary by Sourcery

Introduce a context manager to await new pages and refine WindowManager’s page tracking by improving cleanup, detection and support for BrowserWaiter in navigation and closure methods

New Features:

  • Add expect_new_page() context manager to wait for and wrap new browser pages created via window.open or target="_blank"

Enhancements:

  • Improve all_browsers property to better cleanup closed pages and detect transitional new pages

Tests:

  • Add test_js_window_open_detection to verify automatic detection of tabs and popups opened via JavaScript or anchor links

@sourcery-ai
Copy link

sourcery-ai bot commented Nov 6, 2025

Reviewer's Guide

This PR introduces a new expect_new_page context manager that leverages Playwright’s expect_page API to reliably wait for and wrap newly opened pages, refactors all_browsers to improve cleanup and best-effort detection of pages in transition, extends switch_to and close_browser to accept the new context manager’s return values, and adds an end-to-end test for JS-triggered window openings.

Sequence diagram for expect_new_page context manager usage

sequenceDiagram
    actor User
    participant WindowManager
    participant _BrowserWaiter
    participant PlaywrightContext
    participant Browser
    participant Page
    User->>WindowManager: with expect_new_page() as new_browser
    WindowManager->>_BrowserWaiter: create _BrowserWaiter
    _BrowserWaiter->>PlaywrightContext: expect_page(timeout)
    PlaywrightContext-->>_BrowserWaiter: returns page_info
    User->>Browser: browser.click("a[target='_blank']")
    _BrowserWaiter->>Page: wait_for_load_state("domcontentloaded")
    _BrowserWaiter->>WindowManager: _wrap_page(new_page)
    WindowManager-->>Browser: returns wrapped Browser
    _BrowserWaiter-->>User: yields new_browser (wrapped Browser)
    User->>WindowManager: close_browser(new_browser)
    WindowManager->>Browser: close
Loading

Class diagram for new and updated window management classes

classDiagram
    class WindowManager {
        +expect_new_page(timeout: float = 5.0)
        +all_browsers: List[Browser]
        +all_pages: List[Page]
        +switch_to(browser_or_page: Union[Browser, Page, BrowserWaiter])
        +close_browser(browser_or_page: Optional[Union[Browser, Page, BrowserWaiter]] = None)
        -_wrap_page(page: Page)
        -_context
        -_browsers
    }
    class _BrowserWaiter {
        +__enter__() _BrowserWaiter
        +__exit__(*args)
        +__getattr__(name: str)
        +__eq__(other: Any)
        -window_manager: WindowManager
        -context
        -timeout_ms: int
        -page_info: Optional[Any]
        -_browser: Optional[Browser]
        -_initial_browsers: Optional[Set[Browser]]
    }
    WindowManager "1" -- "*" _BrowserWaiter : creates
    _BrowserWaiter "1" -- "1" WindowManager : references
    _BrowserWaiter "1" -- "1" Browser : wraps
    WindowManager "*" -- "1" Browser : manages
    Browser "1" -- "1" Page : wraps
Loading

File-Level Changes

Change Details Files
Add expect_new_page context manager using Playwright’s expect_page under the hood
  • Define expect_new_page method that returns a nested _BrowserWaiter class
  • Implement enter/exit to hook into context.expect_page and wrap/fallback on new pages
  • Delegate attribute access and equality to the wrapped Browser instance
  • Document usage and examples in the method docstring
src/widgetastic/browser.py
Refactor all_browsers to improve cleanup and best-effort new-page detection
  • Simplify closed-page cleanup and ensure all context.pages are wrapped
  • Introduce a short wait_for loop (_check_for_new_pages) to catch pages in transition
  • Add final filtering to return only active browsers
  • Update doc note to recommend expect_new_page for reliable detection
src/widgetastic/browser.py
Support BrowserWaiter instances in switch_to and close_browser
  • Unwrap _browser attribute from BrowserWaiter before switching
  • Handle BrowserWaiter in close_browser in the same manner
  • Update docstrings and examples to reference the new usage
src/widgetastic/browser.py
Add integration test for JS-triggered window openings
  • Introduce test_js_window_open_detection with multiple scenarios (window.open, new tab, target=_blank)
  • Use expect_new_page in each scenario and verify URL, title, inclusion in all_browsers
  • Ensure proper cleanup by closing and switching back
testing/test_window_manager.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `src/widgetastic/browser.py:1761-1770` </location>
<code_context>
+
+                new_page = None
+                self.page_info.__exit__(*args)
+                try:
+                    new_page = self.page_info.value
+                except AttributeError:
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Silent failure on TimedOutError may obscure issues.

Logging a debug message when TimedOutError is caught will make it easier to identify unexpected failures in new page detection.

Suggested implementation:

```python
                if new_page:
                    # Wait for page to load and wrap it
                    try:
                        new_page.wait_for_load_state("domcontentloaded", timeout=5000)
                    except TimedOutError:
                        logger.debug("Timed out waiting for new page to load (domcontentloaded).")
                    except Exception:
                        pass
                    self._browser = self.window_manager._wrap_page(new_page)

```

```python
import logging
from playwright.sync_api import TimedOutError

logger = logging.getLogger(__name__)

```

If `logger` or `TimedOutError` are already imported elsewhere in the file, you can omit those import lines. Make sure the logging configuration is set up somewhere in your application to see debug messages.
</issue_to_address>

### Comment 2
<location> `testing/test_window_manager.py:365-374` </location>
<code_context>
+def test_js_window_open_detection(
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding negative test cases for failed window/tab opening.

Adding tests for scenarios where window/tab opening fails, such as blocked popups or links that do not open new pages, will help confirm that `expect_new_page` handles timeouts and errors as expected.

Suggested implementation:

```python
def test_js_window_open_detection(
    isolated_window_manager,
    popup_test_page_url,
):
    """Test that new tabs/popups opened via JavaScript or links are automatically detected.

    This test verifies that WindowManager correctly detects new pages opened through:
    - JavaScript window.open() for popup windows
    - JavaScript window.open() for new tabs
    - HTML anchor tags with target="_blank"

    It also verifies negative cases where no new window/tab is opened.
    """

    # Positive test cases (existing)
    # ... (existing positive test code here)

    # Negative test case 1: No action taken, expect_new_page should timeout or raise
    with isolated_window_manager.current.page.expect_new_page(timeout=1000) as new_page_info:
        pass  # No action, should not open a new page
    try:
        new_page_info.value
        assert False, "expect_new_page should have timed out when no new page is opened"
    except Exception:
        pass  # Expected

    # Negative test case 2: Click a link without target="_blank", should not open a new page
    page = isolated_window_manager.current.page
    page.goto(popup_test_page_url)
    # Assume there is a link with id 'no_blank_link' that does not open a new tab
    try:
        with page.expect_new_page(timeout=1000) as new_page_info:
            page.click("#no_blank_link")
        new_page_info.value
        assert False, "expect_new_page should have timed out for link without target=_blank"
    except Exception:
        pass  # Expected

    # Negative test case 3: Simulate popup blocked by browser
    # This may require mocking or simulating browser settings, but we can try a JS call that is known to be blocked
    try:
        with page.expect_new_page(timeout=1000) as new_page_info:
            page.evaluate("window.open('about:blank', '_blank', 'popup,noopener')")
        new_page_info.value
        assert False, "expect_new_page should have timed out if popup is blocked"
    except Exception:
        pass  # Expected

```

- You may need to adjust the selectors (e.g., "#no_blank_link") to match actual elements in your test page.
- If your test framework uses a different way to handle timeouts or exceptions, adapt the try/except blocks accordingly.
- Ensure the positive test cases are still present and run before the negative cases.
</issue_to_address>

### Comment 3
<location> `testing/test_window_manager.py:398-432` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Avoid loops in tests. ([`no-loop-in-tests`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/no-loop-in-tests))

<details><summary>Explanation</summary>Avoid complex code, like loops, in test functions.

Google's software engineering guidelines says:
"Clear tests are trivially correct upon inspection"
To reach that avoid complex code in tests:
* loops
* conditionals

Some ways to fix this:

* Use parametrized tests to get rid of the loop.
* Move the complex logic into helpers.
* Move the complex part into pytest fixtures.

> Complexity is most often introduced in the form of logic. Logic is defined via the imperative parts of programming languages such as operators, loops, and conditionals. When a piece of code contains logic, you need to do a bit of mental computation to determine its result instead of just reading it off of the screen. It doesn't take much logic to make a test more difficult to reason about.

Software Engineering at Google / [Don't Put Logic in Tests](https://abseil.io/resources/swe-book/html/ch12.html#donapostrophet_put_logic_in_tests)
</details>
</issue_to_address>

### Comment 4
<location> `src/widgetastic/browser.py:1714` </location>
<code_context>
    def expect_new_page(self, timeout: float = 5.0):
        """Context manager to wait for a new page and return the wrapped Browser instance.

        This method uses Playwright's native `expect_page()` to wait for a new page
        to be created. Use this when you know an action (like clicking a link with
        target="_blank" or a button that calls window.open()) will open a new page.

        The context manager automatically waits for the page to load (domcontentloaded).

        Args:
            timeout: Maximum time to wait for the new page in seconds (default: 5.0)

        Yields:
            Browser: The wrapped Browser instance for the new page

        Example:
            .. code-block:: python

                # Get Browser directly - returns actual Browser instance
                with window_manager.expect_new_page() as new_browser:
                    browser.click("a[target='_blank']")
                # new_browser is the wrapped Browser instance, ready to use
                assert new_browser.title == "New Page"
                new_browser.element("#submit").click()
                window_manager.close_browser(new_browser)

                # Or with a longer timeout
                with window_manager.expect_new_page(timeout=10.0) as popup_browser:
                    browser.click("#open-popup-button")
                popup_browser.element("#confirm").click()
        """

        class _BrowserWaiter:
            """Simple context manager that waits for page and returns wrapped Browser."""

            def __init__(self, window_manager: "WindowManager", timeout_ms: int) -> None:
                self.window_manager = window_manager
                self.context = window_manager._context
                self.timeout_ms = timeout_ms
                self.page_info: Optional[Any] = None
                self._browser: Optional[Browser] = None
                self._initial_browsers: Optional[Set[Browser]] = None

            def __enter__(self) -> "_BrowserWaiter":
                # Capture initial browsers before setting up expectation
                self._initial_browsers = set(self.window_manager.all_browsers)
                self.page_info = self.context.expect_page(timeout=self.timeout_ms)
                self.page_info.__enter__()
                return self

            def __exit__(self, *args: Any) -> None:
                # Exit Playwright's context manager to get the Page/Browser
                if not self.page_info:
                    # If page_info was never set, something went wrong in __enter__
                    raise RuntimeError(
                        "expect_new_page context manager was not properly initialized. "
                        "This should not happen."
                    )

                new_page = None
                self.page_info.__exit__(*args)
                try:
                    new_page = self.page_info.value
                except AttributeError:
                    # value might not be available in some edge cases
                    pass

                if new_page:
                    # Wait for page to load and wrap it
                    try:
                        new_page.wait_for_load_state("domcontentloaded", timeout=5000)
                    except Exception:
                        pass
                    self._browser = self.window_manager._wrap_page(new_page)
                else:
                    # expect_page succeeded but value is None.This can happen if the event handler already wrapped the page
                    # Try to find it by comparing browser sets
                    if self._initial_browsers is None:
                        raise RuntimeError(
                            "expect_new_page context manager was not properly initialized. "
                            "This should not happen."
                        )
                    current_browsers = set(self.window_manager.all_browsers)
                    new_browsers = current_browsers - self._initial_browsers
                    if new_browsers:
                        self._browser = next(iter(new_browsers))
                    else:
                        # No new page found - this shouldn't happen if expect_page succeeded
                        raise RuntimeError(
                            "expect_page succeeded but no new page was found. "
                            "This may indicate a timing issue or the page was closed immediately."
                        )

            def __getattr__(self, name: str) -> Any:
                """Delegate all attribute access to the wrapped Browser."""
                if self._browser is None:
                    raise RuntimeError(
                        "Browser is not available yet. Access it after the 'with' block exits."
                    )
                return getattr(self._browser, name)

            def __eq__(self, other: Any) -> bool:
                """Compare by page identity for 'in' checks to work."""
                if self._browser is None:
                    return False
                if isinstance(other, Browser):
                    return self._browser.page is other.page
                return False

        return _BrowserWaiter(self, int(timeout * 1000))

</code_context>

<issue_to_address>
**issue (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@codecov
Copy link

codecov bot commented Nov 6, 2025

Codecov Report

❌ Patch coverage is 73.03371% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.77%. Comparing base (4167850) to head (3bee93a).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/widgetastic/browser.py 73.03% 24 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #293      +/-   ##
==========================================
- Coverage   93.50%   92.77%   -0.74%     
==========================================
  Files          19       19              
  Lines        2617     2698      +81     
==========================================
+ Hits         2447     2503      +56     
- Misses        170      195      +25     
Flag Coverage Δ
unittests 92.77% <73.03%> (-0.74%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@digitronik digitronik force-pushed the js_windows_hanlding branch 3 times, most recently from 255556a to 9d49373 Compare November 6, 2025 08:36
@digitronik digitronik self-assigned this Nov 6, 2025
@digitronik digitronik requested a review from mshriver November 6, 2025 10:49
@mshriver mshriver self-assigned this Nov 6, 2025
@mshriver mshriver merged commit 4e355ef into RedHatQE:main Nov 6, 2025
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants