diff --git a/notebook/tests/selenium/conftest.py b/notebook/tests/selenium/conftest.py index d7227b12b..232b5bc4a 100644 --- a/notebook/tests/selenium/conftest.py +++ b/notebook/tests/selenium/conftest.py @@ -12,6 +12,7 @@ from selenium.webdriver import Firefox, Remote, Chrome pjoin = os.path.join + def _wait_for_server(proc, info_file_path): """Wait 30 seconds for the notebook server to start""" for i in range(300): @@ -28,6 +29,7 @@ def _wait_for_server(proc, info_file_path): time.sleep(0.1) raise RuntimeError("Didn't find %s in 30 seconds", info_file_path) + @pytest.fixture(scope='session') def notebook_server(): info = {} @@ -50,10 +52,11 @@ def notebook_server(): # run with a base URL that would be escaped, # to test that we don't double-escape URLs '--NotebookApp.base_url=/a@b/', - ] + ] print("command=", command) proc = info['popen'] = Popen(command, cwd=nbdir, env=env) - info_file_path = pjoin(td, 'jupyter_runtime', 'nbserver-%i.json' % proc.pid) + info_file_path = pjoin(td, 'jupyter_runtime', + 'nbserver-%i.json' % proc.pid) info.update(_wait_for_server(proc, info_file_path)) print("Notebook server info:", info) @@ -63,26 +66,38 @@ def notebook_server(): requests.post(urljoin(info['url'], 'api/shutdown'), headers={'Authorization': 'token '+info['token']}) + +def make_sauce_driver(): + """This function helps travis create a driver on Sauce Labs. + + This function will err if used without specifying the variables expected + in that context. + """ + + username = os.environ["SAUCE_USERNAME"] + access_key = os.environ["SAUCE_ACCESS_KEY"] + capabilities = { + "tunnel-identifier": os.environ["TRAVIS_JOB_NUMBER"], + "build": os.environ["TRAVIS_BUILD_NUMBER"], + "tags": [os.environ['TRAVIS_PYTHON_VERSION'], 'CI'], + "platform": "Windows 10", + "browserName": os.environ['JUPYTER_TEST_BROWSER'], + "version": "latest", + } + if capabilities['browserName'] == 'firefox': + # Attempt to work around issue where browser loses authentication + capabilities['version'] = '57.0' + hub_url = "%s:%s@localhost:4445" % (username, access_key) + print("Connecting remote driver on Sauce Labs") + driver = Remote(desired_capabilities=capabilities, + command_executor="http://%s/wd/hub" % hub_url) + return driver + + @pytest.fixture(scope='session') def selenium_driver(): if os.environ.get('SAUCE_USERNAME'): - username = os.environ["SAUCE_USERNAME"] - access_key = os.environ["SAUCE_ACCESS_KEY"] - capabilities = { - "tunnel-identifier": os.environ["TRAVIS_JOB_NUMBER"], - "build": os.environ["TRAVIS_BUILD_NUMBER"], - "tags": [os.environ['TRAVIS_PYTHON_VERSION'], 'CI'], - "platform": "Windows 10", - "browserName": os.environ['JUPYTER_TEST_BROWSER'], - "version": "latest", - } - if capabilities['browserName'] == 'firefox': - # Attempt to work around issue where browser loses authentication - capabilities['version'] = '57.0' - hub_url = "%s:%s@localhost:4445" % (username, access_key) - print("Connecting remote driver on Sauce Labs") - driver = Remote(desired_capabilities=capabilities, - command_executor="http://%s/wd/hub" % hub_url) + driver = make_sauce_driver() elif os.environ.get('JUPYTER_TEST_BROWSER') == 'chrome': driver = Chrome() else: @@ -93,7 +108,8 @@ def selenium_driver(): # Teardown driver.quit() -@pytest.fixture + +@pytest.fixture(scope='module') def authenticated_browser(selenium_driver, notebook_server): selenium_driver.jupyter_server_info = notebook_server selenium_driver.get("{url}?token={token}".format(**notebook_server)) diff --git a/notebook/tests/selenium/quick_selenium.py b/notebook/tests/selenium/quick_selenium.py new file mode 100644 index 000000000..10e46e1f3 --- /dev/null +++ b/notebook/tests/selenium/quick_selenium.py @@ -0,0 +1,53 @@ +"""Utilities for driving Selenium interactively to develop tests. + +These are not used in the tests themselves - rather, the developer writing tests +can use them to experiment with Selenium. +""" +from selenium.webdriver import Firefox + +from notebook.tests.selenium.utils import Notebook +from notebook.notebookapp import list_running_servers + +class NoServerError(Exception): + + def __init__(self, message): + self.message = message + +def quick_driver(lab=False): + """Quickly create a selenium driver pointing at an active noteboook server. + + Usage example: + + from notebook.tests.selenium.quick_selenium import quick_driver + driver = quick_driver + + Note: you need to manually close the driver that opens with driver.quit() + """ + try: + server = list(list_running_servers())[0] + except IndexError as e: + raise NoServerError('You need a server running before you can run ' + 'this command') + driver = Firefox() + auth_url = '{url}?token={token}'.format(**server) + driver.get(auth_url) + + # If this redirects us to a lab page and we don't want that; + # then we need to redirect ourselves to the classic notebook view + if driver.current_url.endswith('/lab') and not lab: + driver.get(driver.current_url.rstrip('lab')+'tree') + return driver + + +def quick_notebook(): + """Quickly create a new classic notebook in a selenium driver + + + Usage example: + + from notebook.tests.selenium.quick_selenium import quick_notebook + nb = quick_notebook() + + Note: you need to manually close the driver that opens with nb.browser.quit() + """ + return Notebook.new_notebook(quick_driver()) diff --git a/notebook/tests/selenium/test_dashboard_nav.py b/notebook/tests/selenium/test_dashboard_nav.py index 9b588c75e..8e0997902 100644 --- a/notebook/tests/selenium/test_dashboard_nav.py +++ b/notebook/tests/selenium/test_dashboard_nav.py @@ -5,6 +5,7 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from notebook.utils import url_path_join +from notebook.tests.selenium.utils import wait_for_selector pjoin = os.path.join @@ -40,7 +41,6 @@ def get_list_items(browser): 'element': a, } for a in browser.find_elements_by_class_name('item_link')] - def only_dir_links(browser): """Return only links that point at other directories in the tree @@ -49,12 +49,6 @@ def only_dir_links(browser): return [i for i in items if url_in_tree(browser, i['link']) and i['label'] != '..'] - -def wait_for_selector(browser, selector, timeout=10): - wait = WebDriverWait(browser, timeout) - return wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, selector))) - - def test_items(authenticated_browser): visited_dict = {} # Going down the tree to collect links diff --git a/notebook/tests/selenium/test_markdown.py b/notebook/tests/selenium/test_markdown.py new file mode 100644 index 000000000..c71ef8a17 --- /dev/null +++ b/notebook/tests/selenium/test_markdown.py @@ -0,0 +1,43 @@ +import os + +import pytest +from selenium.webdriver.common.keys import Keys + +from .utils import wait_for_selector, Notebook + +pjoin = os.path.join + + +@pytest.fixture +def notebook(authenticated_browser): + return Notebook.new_notebook(authenticated_browser) + + +def get_rendered_contents(nb): + cl = ["text_cell", "render"] + rendered_cells = [cell.find_element_by_class_name("text_cell_render") + for cell in nb.cells + if all([c in cell.get_attribute("class") for c in cl])] + return [x.get_attribute('innerHTML').strip() + for x in rendered_cells + if x is not None] + + +def test_markdown_cell(notebook): + nb = notebook + cell_text = ["# Foo", + '**Bar**', + '*Baz*', + '```\nx = 1\n```', + '```aaaa\nx = 1\n```', + ] + expected_contents = ['

Foo

', + '

Bar

', + '

Baz

', + '
x = 1\n
', + '
x = 1\n
' + ] + nb.append(*cell_text, cell_type="markdown") + nb.run_all() + rendered_contents = get_rendered_contents(nb) + assert rendered_contents == expected_contents diff --git a/notebook/tests/selenium/utils.py b/notebook/tests/selenium/utils.py new file mode 100644 index 000000000..3aed1d554 --- /dev/null +++ b/notebook/tests/selenium/utils.py @@ -0,0 +1,222 @@ +import os + +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.remote.webelement import WebElement + +from contextlib import contextmanager + +pjoin = os.path.join + + +def wait_for_selector(browser, selector, timeout=10, visible=False, single=False): + wait = WebDriverWait(browser, timeout) + if single: + if visible: + conditional = EC.visibility_of_element_located + else: + conditional = EC.presence_of_element_located + else: + if visible: + conditional = EC.visibility_of_all_elements_located + else: + conditional = EC.presence_of_all_elements_located + return wait.until(conditional((By.CSS_SELECTOR, selector))) + + +class CellTypeError(ValueError): + + def __init__(self, message=""): + self.message = message + + +class Notebook: + + def __init__(self, browser): + self.browser = browser + self.disable_autosave_and_onbeforeunload() + + def __len__(self): + return len(self.cells) + + def __getitem__(self, key): + return self.cells[key] + + def __setitem__(self, key, item): + if isinstance(key, int): + self.edit_cell(index=key, content=item, render=False) + # TODO: re-add slicing support, handle general python slicing behaviour + # includes: overwriting the entire self.cells object if you do + # self[:] = [] + # elif isinstance(key, slice): + # indices = (self.index(cell) for cell in self[key]) + # for k, v in zip(indices, item): + # self.edit_cell(index=k, content=v, render=False) + + def __iter__(self): + return (cell for cell in self.cells) + + @property + def body(self): + return self.browser.find_element_by_tag_name("body") + + @property + def cells(self): + """Gets all cells once they are visible. + + """ + return self.browser.find_elements_by_class_name("cell") + + @property + def current_index(self): + return self.index(self.current_cell) + + def index(self, cell): + return self.cells.index(cell) + + def disable_autosave_and_onbeforeunload(self): + """Disable request to save before closing window and autosave. + + This is most easily done by using js directly. + """ + self.browser.execute_script("window.onbeforeunload = null;") + self.browser.execute_script("Jupyter.notebook.set_autosave_interval(0)") + + def to_command_mode(self): + """Changes us into command mode on currently focused cell + + """ + self.body.send_keys(Keys.ESCAPE) + self.browser.execute_script("return Jupyter.notebook.handle_command_mode(" + "Jupyter.notebook.get_cell(" + "Jupyter.notebook.get_edit_index()))") + + def focus_cell(self, index=0): + cell = self.cells[index] + cell.click() + self.to_command_mode() + self.current_cell = cell + + def convert_cell_type(self, index=0, cell_type="code"): + # TODO add check to see if it is already present + self.focus_cell(index) + cell = self.cells[index] + if cell_type == "markdown": + self.current_cell.send_keys("m") + elif cell_type == "raw": + self.current_cell.send_keys("r") + elif cell_type == "code": + self.current_cell.send_keys("y") + else: + raise CellTypeError(("{} is not a valid cell type," + "use 'code', 'markdown', or 'raw'").format(cell_type)) + + self.wait_for_stale_cell(cell) + self.focus_cell(index) + return self.current_cell + + def wait_for_stale_cell(self, cell): + """ This is needed to switch a cell's mode and refocus it, or to render it. + + Warning: there is currently no way to do this when changing between + markdown and raw cells. + """ + wait = WebDriverWait(self.browser, 10) + element = wait.until(EC.staleness_of(cell)) + + def edit_cell(self, cell=None, index=0, content="", render=False): + if cell is not None: + index = self.index(cell) + self.focus_cell(index) + + for line_no, line in enumerate(content.splitlines()): + if line_no != 0: + self.current_cell.send_keys(Keys.ENTER, "\n") + self.current_cell.send_keys(Keys.ENTER, line) + if render: + self.execute_cell(self.current_index) + + def execute_cell(self, cell_or_index=None): + if isinstance(cell_or_index, int): + index = cell_or_index + elif isinstance(cell_or_index, WebElement): + index = self.index(cell_or_index) + else: + raise TypeError("execute_cell only accepts a WebElement or an int") + self.focus_cell(index) + self.current_cell.send_keys(Keys.CONTROL, Keys.ENTER) + + def add_cell(self, index=-1, cell_type="code", content=""): + self.focus_cell(index) + self.current_cell.send_keys("b") + new_index = index + 1 if index >= 0 else index + if content: + self.edit_cell(index=index, content=content) + self.convert_cell_type(index=new_index, cell_type=cell_type) + + def add_markdown_cell(self, index=-1, content="", render=True): + self.add_cell(index, cell_type="markdown") + self.edit_cell(index=index, content=content, render=render) + + def append(self, *values, cell_type="code"): + for i, value in enumerate(values): + if isinstance(value, str): + self.add_cell(cell_type=cell_type, + content=value) + else: + raise TypeError("Don't know how to add cell from %r" % value) + + def extend(self, values): + self.append(*values) + + def run_all(self): + for cell in self: + self.execute_cell(cell) + + @classmethod + def new_notebook(cls, browser, kernel_name='kernel-python3'): + with new_window(browser, selector=".cell"): + select_kernel(browser, kernel_name=kernel_name) + return cls(browser) + + +def select_kernel(browser, kernel_name='kernel-python3'): + """Clicks the "new" button and selects a kernel from the options. + """ + new_button = wait_for_selector(browser, "#new-buttons", single=True) + new_button.click() + kernel_selector = '#{} a'.format(kernel_name) + kernel = wait_for_selector(browser, kernel_selector, single=True) + kernel.click() + +@contextmanager +def new_window(browser, selector=None): + """Contextmanager for switching to & waiting for a window created. + + This context manager gives you the ability to create a new window inside + the created context and it will switch you to that new window. + + If you know a CSS selector that can be expected to appear on the window, + then this utility can wait on that selector appearing on the page before + releasing the context. + + Usage example: + + from notebook.tests.selenium.utils import new_window, Notebook + + ⋮ # something that creates a browser object + + with new_window(browser, selector=".cell"): + select_kernel(browser, kernel_name=kernel_name) + nb = Notebook(browser) + + """ + initial_window_handles = browser.window_handles + yield + new_window_handle = next(window for window in browser.window_handles + if window not in initial_window_handles) + browser.switch_to_window(new_window_handle) + if selector is not None: + wait_for_selector(browser, selector)