capybara.py icon indicating copy to clipboard operation
capybara.py copied to clipboard

onbeforeunload not handled correctly in firefox-webdriver

Open dwt opened this issue 4 years ago • 4 comments

Hi there,

it seems that there is code that is supposed to handle this condition, but it doesn't work for me - i.e. hangs indefinitely. Also, if the onbeforeunload handler is triggered in a different (background) window, it seems that this condition is not handled at all, but instead the window just gets a close event and doesn't close.

Here's my reproducer:

# https://github.com/elliterate/capybara.py

import capybara
from capybara.dsl import page

from conftest import find_firefox

HEADLESS = True
HEADLESS = False

@capybara.register_driver("selenium")
def init_selenium_driver(app):
    
    from selenium.webdriver.firefox.options import Options
    options = Options()
    options.binary_location = find_firefox()
    options.headless = HEADLESS
    # otherwise marionette automatically disables beforeunload event handling
    # still requires interaction to trigger
    options.set_preference("dom.disable_beforeunload", False)
    
    from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
    
    capabilities = DesiredCapabilities.FIREFOX.copy()
    capabilities["marionette"] = True
    
    from capybara.selenium.driver import Driver
    
    return Driver(app, browser="firefox", options=options, desired_capabilities=capabilities,
        # cannot set these after the fact, so we set them here
        clear_local_storage=True,
        clear_session_storage=True,
    )


capybara.default_driver = "selenium"
capybara.default_max_wait_time = 5

def test_isolation(flask_uri, ask_to_leave_script):
    page.visit(flask_uri)
    
    # onbeforeunload dialog
    # bug in capybara, ask to leave script is only handled in current window, other windows just get closed and then hang
    # Even though it is handled in the code, that doesn't work for firefox. (?)
    page.execute_script(ask_to_leave_script)
    # page interaction, so onbeforeunload is actually triggered
    page.fill_in('input_label', value='fnord')
    
    # bug in capybara: background windows don't even have code to handle dialogs like onbeforeunload
    with page.window(page.open_new_window()):
        page.visit(flask_uri)
        page.execute_script(ask_to_leave_script)
        # page interaction, so onbeforeunload is actually triggered
        page.fill_in('input_label', value='fnord')
    
    page.reset()  # hangs on the onbeforeunload handlers
    
    assert len(page.windows) == 1
    assert page.current_url == 'about:blank'

dwt avatar Nov 26 '21 08:11 dwt

I realize I missed the conftest.py file:

import subprocess
import pytest
import re
import atexit
from subprocess import run

def find_firefox():
    paths = run(['mdfind', 'kMDItemFSName == Firefox.app'], capture_output=True).stdout.splitlines()
    assert len(paths) > 0
    return paths[0].strip().decode() + '/Contents/MacOS/firefox'

@pytest.fixture(scope='session')
def flask_uri():
    with subprocess.Popen(['flask', 'run', '--reload'], stderr=subprocess.PIPE, encoding='utf8') as process:
        flask_url = None
        still_starting = True
        while still_starting:
            output = process.stderr.readline()
            match = re.match(r'^.* Running on (http://[\d\.\:]+/).*$', output)
            still_starting = match is None
                
        flask_url = match.group(1)
        
        def kill():
            process.terminate()
            process.wait()
        
        atexit.register(kill)
        
        yield flask_url
        
        kill()

@pytest.fixture
def ask_to_leave_script():
    return '''
        // interestingly Firefox webdriver doesn't show thes dialogs at all, even though this code works in normal Firefox
        window.addEventListener('beforeunload', function (e) {
            // Cancel the event
            e.preventDefault(); // mozilla will now always show dialog
            // Chrome requires returnValue to be set
            e.returnValue = 'Fnord';
        });
    '''

# Selenium style xpath matcher
@pytest.fixture
def xpath():
    class XPath:
        def __getattr__(self, name):
            from xpath import html
            from xpath.renderer import to_xpath
            
            def callable(*args, **kwargs):
                return to_xpath(getattr(html, name)(*args, **kwargs))
            
            return callable
    
    return XPath()


def assert_is_png(path):
    assert_is_file(path, '.png', b'PNG image data', b'8-bit/color RGBA, non-interlaced')

def assert_is_file(path, expected_suffix, *expected_file_outputs):
    assert path.exists() and path.is_file()
    assert path.stat().st_size > 1000
    assert path.suffix == expected_suffix
    
    import subprocess
    output = subprocess.check_output(['file', path])
    for expected_output in expected_file_outputs:
        assert expected_output in output

from contextlib import contextmanager
from datetime import datetime
@contextmanager
def assert_no_slower_than(seconds=1):
    before = datetime.now()
    yield
    after = datetime.now()
    assert (after - before).total_seconds() < seconds

dwt avatar Nov 26 '21 08:11 dwt

And the flask app in question:

import flask

app = flask.Flask(__name__)

@app.get('/')
def index():
    return flask.redirect('/selector_playground')

@app.get("/dynamic_disclose")
def dynamic_disclosure():
    return '''
    <div id=container>
        <div id=outer>
            Container
            <div id=inner>
            </div>
        </div>
    </div>
    <button onclick=trigger()>Trigger</button>
    <script>
    function trigger() {
        div = document.querySelectorAll('#container')[0]
        setTimeout(function() {
            div.innerHTML = "<div id=outer><div id=inner>fnord</div></div>"
        }, 1000)
    }
    </script>
    '''

@app.get('/form')
def form():
    return '''
    <form>
        <label for=first_name>First name:</label><input id=first_name>
        <label>Last name:<input id=last_name></label>
        <input id=email placeholder=your@email>
    </form>
    '''

@app.get('/selector_playground')
def selector_playground():
    return '''
    <form>
        <label for=input_id id=label>input_label</label>
        <input id=input_id class=input_class name=input_name value=input_value 
            title=input_title placeholder=input_placeholder aria-label=input_aria_label>
        <div id=div_id>div_text</div>
    </form>
    '''

dwt avatar Nov 26 '21 08:11 dwt

I believe the unload handling in Capybara is meant to be best-effort and not exhaustive. That is, I'm pretty sure the Ruby library makes no effort to deal with extra windows that may have been left open by one's tests.

If you really want to use and test an unload handler, you could copy the cleanup logic into your own test teardown function.

elliterate avatar Nov 26 '21 14:11 elliterate

:-) Well I sure can deal with this manually - and especially here where I'm comparing how different browser automation frameworks deal with this.

Im reporting the bug cause I noticed that there was code, but it didn't work. Would you be open to a pull request that fixes this (for the current window?)

What about background windows, it seems sensible that the same logic is applied to closing them as the main window (why have a different code path) - I haven't looked into capybara yet, but do you know a reason why different logic should apply for background windows?

dwt avatar Nov 27 '21 08:11 dwt