diff --git a/.github/tests-browser.yml b/.github/tests-browser.yml new file mode 100644 index 0000000000..04228b64ac --- /dev/null +++ b/.github/tests-browser.yml @@ -0,0 +1,16 @@ +name: test + +channels: + - conda-forge + - nodefaults + +dependencies: + # browser deps + - firefox >=78,<79 + - geckodriver + - nodejs >=12,<13 + - pandoc + # rest of python deps + - pip + - setuptools + - wheel diff --git a/.github/workflows/tests-browser.yml b/.github/workflows/tests-browser.yml new file mode 100644 index 0000000000..0ef37c5fcc --- /dev/null +++ b/.github/workflows/tests-browser.yml @@ -0,0 +1,78 @@ +name: Browser Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + JUPYTER_TEST_BROWSER: firefox + MOZ_HEADLESS: 1 + +jobs: + build: + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu, macos, windows] + python-version: ['3.6', '3.8', '3.9'] + defaults: + run: + shell: bash -l {0} + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Cache conda + uses: actions/cache@v1 + with: + path: ~/conda_pkgs_dir + key: + ${{ runner.os }}-conda-${{ matrix.python-version }}-${{ hashFiles('.github/tests-browser.yml') }} + restore-keys: | + ${{ runner.os }}-conda-${{ matrix.python-version }}- + - name: Install Browser Test Dependencies + uses: conda-incubator/setup-miniconda@v2 + with: + python-version: ${{ matrix.python-version }} + environment-file: .github/tests-browser.yml + - if: ${{ matrix.os == 'windows' }} + name: Install windows conda dependencies + run: conda install -c conda-forge -c msys2 -c nodefaults pywin32 + - name: List conda packages + run: | + conda list --explicit + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache pip + uses: actions/cache@v1 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('setup.py') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - name: Build static assets + run: | + python setup.py build + - name: Install the Python dependencies + run: | + pip install -e .[test] codecov + - name: List installed packages + run: | + pip freeze + pip check + - name: Run selenium tests + timeout-minutes: 30 + run: | + py.test -sv notebook/tests/selenium ${{ matrix.selenium-pytest-args }} + - name: Install npm dependencies + run: | + npm install -g casperjs@1.1.3 phantomjs-prebuilt@2.1.7 + - name: Run legacy js tests + timeout-minutes: 30 + run: | + python -m notebook.jstest diff --git a/.github/workflows/tests-python.yml b/.github/workflows/tests-python.yml new file mode 100644 index 0000000000..39d1abfee6 --- /dev/null +++ b/.github/workflows/tests-python.yml @@ -0,0 +1,66 @@ +name: Python Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu, macos, windows] + python-version: ['3.6', '3.7', '3.8', '3.9', 'pypy3'] + exclude: + - os: windows + python-version: pypy3 + include: + # TODO: remove if fixed https://github.com/spyder-ide/pywinpty/issues/134 + - os: windows + python-version: '3.9' + pytest-args: --ignore notebook/terminal + - python-version: pypy3 + pytest-args: -k "not culling" + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Install Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + - name: Upgrade packaging dependencies + run: | + pip install --upgrade pip setuptools wheel --user + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache pip + uses: actions/cache@v1 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('setup.py') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - uses: actions/setup-node@v1 + with: + node-version: '12' + - name: Build static assets + run: | + python setup.py build + - name: Install the Python dependencies + run: | + pip install -e .[test] codecov + - name: List installed packages + run: | + pip freeze + pip check + - name: Run the tests + timeout-minutes: 30 + run: | + pytest -vv notebook --cov notebook --ignore notebook/tests/selenium --cov-branch --cov-report term-missing:skip-covered --durations 10 ${{ matrix.pytest-args }} diff --git a/notebook/auth/login.py b/notebook/auth/login.py index 1ac434dc5e..db564aa533 100644 --- a/notebook/auth/login.py +++ b/notebook/auth/login.py @@ -73,13 +73,13 @@ def hashed_password(self): def passwd_check(self, a, b): return passwd_check(a, b) - + def post(self): typed_password = self.get_argument('password', default=u'') new_password = self.get_argument('new_password', default=u'') - + if self.get_login_available(self.settings): if self.passwd_check(self.hashed_password, typed_password) and not new_password: self.set_login_cookie(self, uuid.uuid4().hex) @@ -112,7 +112,7 @@ def set_login_cookie(cls, handler, user_id=None): handler.set_secure_cookie(handler.cookie_name, user_id, **cookie_options) return user_id - auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE) + auth_header_pat = re.compile(r'token\s+(.+)', re.IGNORECASE) @classmethod def get_token(cls, handler): @@ -197,7 +197,7 @@ def get_user(cls, handler): @classmethod def get_user_token(cls, handler): """Identify the user based on a token in the URL or Authorization header - + Returns: - uuid if authenticated - None if not diff --git a/notebook/jstest.py b/notebook/jstest.py index 2bb318af31..a7afc488c8 100644 --- a/notebook/jstest.py +++ b/notebook/jstest.py @@ -32,6 +32,12 @@ def popen_wait(p, timeout): return p.wait(timeout) NOTEBOOK_SHUTDOWN_TIMEOUT = 10 +SKIP_JS_GROUPS = [ + # doesn't appear to do anything + "mockextension", + # has its own test entrypoint + "selenium" +] have = {} have['casperjs'] = bool(which('casperjs')) @@ -60,7 +66,7 @@ def run(self): self.buffer.write(chunk) if self.echo: sys.stdout.write(bytes_to_str(chunk)) - + os.close(self.readfd) os.close(self.writefd) @@ -110,7 +116,7 @@ def __init__(self): def setup(self): """Create temporary directories etc. - + This is only called when we know the test group will be run. Things created here may be cleaned up by self.cleanup(). """ @@ -138,11 +144,11 @@ def wait(self): def print_extra_info(self): """Print extra information about this test run. - + If we're running in parallel and showing the concise view, this is only called if the test group fails. Otherwise, it's called before the test group is started. - + The base implementation does nothing, but it can be overridden by subclasses. """ @@ -189,11 +195,15 @@ def all_js_groups(): import glob test_dir = get_js_test_dir() all_subdirs = glob.glob(test_dir + '[!_]*/') - return [os.path.relpath(x, test_dir) for x in all_subdirs] + return [ + os.path.relpath(x, test_dir) + for x in all_subdirs + if os.path.relpath(x, test_dir) not in SKIP_JS_GROUPS + ] class JSController(TestController): """Run CasperJS tests """ - + requirements = ['casperjs'] def __init__(self, section, xunit=True, engine='phantomjs', url=None): @@ -206,11 +216,11 @@ def __init__(self, section, xunit=True, engine='phantomjs', url=None): # run with a base URL that would be escaped, # to test that we don't double-escape URLs self.base_url = '/a@b/' - self.slimer_failure = re.compile('^FAIL.*', flags=re.MULTILINE) + self.slimer_failure = re.compile(r'^FAIL.*', flags=re.MULTILINE) js_test_dir = get_js_test_dir() includes = '--includes=' + os.path.join(js_test_dir,'util.js') test_cases = os.path.join(js_test_dir, self.section) - self.cmd = ['casperjs', 'test', includes, test_cases, '--engine=%s' % self.engine] + self.cmd = [shutil.which('casperjs'), 'test', includes, test_cases, '--engine=%s' % self.engine] def setup(self): self.ipydir = TemporaryDirectory() @@ -317,7 +327,7 @@ def _init_server(self): 'nbserver-%i.json' % self.server.pid ) self._wait_for_server() - + def _wait_for_server(self): """Wait 30 seconds for the notebook server to start""" for i in range(300): @@ -336,14 +346,14 @@ def _wait_for_server(self): print("Notebook server-info file never arrived: %s" % self.server_info_file, file=sys.stderr ) - + def _failed_to_start(self): """Notebook server exited prematurely""" captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace') print("Notebook failed to start: ", file=sys.stderr) print(self.server_command) print(captured, file=sys.stderr) - + def _load_server_info(self): """Notebook server started, load connection info from JSON""" with open(self.server_info_file) as f: @@ -377,7 +387,7 @@ def cleanup(self): print("Notebook server still running (%s)" % self.server_info_file, file=sys.stderr ) - + self.stream_capturer.halt() TestController.cleanup(self) @@ -399,11 +409,11 @@ def prepare_controllers(options): def do_run(controller, buffer_output=True): """Setup and run a test controller. - + If buffer_output is True, no output is displayed, to avoid it appearing interleaved. In this case, the caller is responsible for displaying test output on failure. - + Returns ------- controller : TestController @@ -468,7 +478,7 @@ def _add(name, value): def run_jstestall(options): """Run the entire Javascript test suite. - + This function constructs TestControllers and runs them in subprocesses. Parameters diff --git a/notebook/templates/tree.html b/notebook/templates/tree.html index d71527fcbf..d31d66098a 100644 --- a/notebook/templates/tree.html +++ b/notebook/templates/tree.html @@ -44,17 +44,17 @@ - + - + - + - + - + @@ -216,7 +216,7 @@ {% block script %} {{super()}} diff --git a/notebook/tests/selenium/conftest.py b/notebook/tests/selenium/conftest.py index 64cdfa23bd..5935f99d0f 100644 --- a/notebook/tests/selenium/conftest.py +++ b/notebook/tests/selenium/conftest.py @@ -1,3 +1,4 @@ +import time import json import nbformat from nbformat.v4 import new_notebook, new_code_cell @@ -66,9 +67,13 @@ def notebook_server(): print("Notebook server info:", info) yield info - # Shut the server down - requests.post(urljoin(info['url'], 'api/shutdown'), - headers={'Authorization': 'token '+info['token']}) + # Shut the server down + requests.post(urljoin(info['url'], 'api/shutdown'), + headers={'Authorization': 'token '+info['token']}) + + while proc.returncode is None: + proc.wait() + time.sleep(5) def make_sauce_driver(): diff --git a/notebook/tests/selenium/utils.py b/notebook/tests/selenium/utils.py index 4407fce39d..08303e7ac1 100644 --- a/notebook/tests/selenium/utils.py +++ b/notebook/tests/selenium/utils.py @@ -1,4 +1,7 @@ import os +import sys +from contextlib import contextmanager + from selenium.webdriver import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -6,7 +9,6 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.remote.webelement import WebElement -from contextlib import contextmanager pjoin = os.path.join @@ -92,29 +94,29 @@ def multiple_found(driver): class CellTypeError(ValueError): - + def __init__(self, message=""): self.message = message class Notebook: - + def __init__(self, browser): self.browser = browser self._wait_for_start() 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 + # includes: overwriting the entire self.cells object if you do # self[:] = [] # elif isinstance(key, slice): # indices = (self.index(cell) for cell in self[key]) @@ -138,20 +140,20 @@ def body(self): @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;") @@ -159,7 +161,7 @@ def disable_autosave_and_onbeforeunload(self): 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(" @@ -171,7 +173,7 @@ def focus_cell(self, index=0): cell.click() self.to_command_mode() self.current_cell = cell - + def select_cell_range(self, initial_index=0, final_index=0): self.focus_cell(initial_index) self.to_command_mode() @@ -201,7 +203,7 @@ def convert_cell_type(self, index=0, cell_type="code"): 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 @@ -209,7 +211,7 @@ def convert_cell_type(self, index=0, cell_type="code"): 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 + Warning: there is currently no way to do this when changing between markdown and raw cells. """ wait = WebDriverWait(self.browser, 10) @@ -240,7 +242,7 @@ def set_cell_metadata(self, index, key, value): def get_cell_type(self, index=0): JS = 'return Jupyter.notebook.get_cell({}).cell_type'.format(index) return self.browser.execute_script(JS) - + def set_cell_input_prompt(self, index, prmpt_val): JS = 'Jupyter.notebook.get_cell({}).set_input_prompt({})'.format(index, prmpt_val) self.browser.execute_script(JS) @@ -267,7 +269,7 @@ def edit_cell(self, cell=None, index=0, content="", render=False): def execute_cell(self, cell_or_index=None): if isinstance(cell_or_index, int): index = cell_or_index - elif isinstance(cell_or_index, WebElement): + elif isinstance(cell_or_index, WebElement): index = self.index(cell_or_index) else: raise TypeError("execute_cell only accepts a WebElement or an int") @@ -295,7 +297,7 @@ def delete_cell(self, index): 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): @@ -303,10 +305,10 @@ def append(self, *values, cell_type="code"): 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) @@ -343,17 +345,17 @@ def select_kernel(browser, kernel_name='kernel-python3'): @contextmanager def new_window(browser): - """Contextmanager for switching to & waiting for a window created. - - This context manager gives you the ability to create a new window inside + """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. - + Usage example: - + from notebook.tests.selenium.utils import new_window, Notebook - + ⋮ # something that creates a browser object - + with new_window(browser): select_kernel(browser, kernel_name=kernel_name) nb = Notebook(browser) @@ -373,7 +375,7 @@ def shift(browser, k): def cmdtrl(browser, k): """Send key combination Ctrl+(k) or Command+(k) for MacOS""" - trigger_keystrokes(browser, "command-%s"%k) if os.uname()[0] == "Darwin" else trigger_keystrokes(browser, "control-%s"%k) + trigger_keystrokes(browser, "command-%s"%k) if sys.platform == "Darwin" else trigger_keystrokes(browser, "control-%s"%k) def alt(browser, k): """Send key combination Alt+(k)""" @@ -400,7 +402,7 @@ def trigger_keystrokes(browser, *keys): browser.send_keys(getattr(Keys, keys[0].upper(), keys[0])) def validate_dualmode_state(notebook, mode, index): - '''Validate the entire dual mode state of the notebook. + '''Validate the entire dual mode state of the notebook. Checks if the specified cell is selected, and the mode and keyboard mode are the same. Depending on the mode given: Command: Checks that no cells are in focus or in edit mode. @@ -462,7 +464,7 @@ def is_focused_on(index): assert is_focused_on(None) #no focused cells assert is_only_cell_edit(None) #no cells in edit mode - + elif mode == 'edit': assert is_focused_on(index) #The specified cell is focused diff --git a/notebook/tests/test_notebookapp.py b/notebook/tests/test_notebookapp.py index d84852e447..b5d4a1966f 100644 --- a/notebook/tests/test_notebookapp.py +++ b/notebook/tests/test_notebookapp.py @@ -113,7 +113,7 @@ def loc(): -pep440re = re.compile('^(\d+)\.(\d+)\.(\d+((a|b|rc)\d+)?)(\.post\d+)?(\.dev\d*)?$') +pep440re = re.compile(r'^(\d+)\.(\d+)\.(\d+((a|b|rc)\d+)?)(\.post\d+)?(\.dev\d*)?$') def raise_on_bad_version(version): if not pep440re.match(version): diff --git a/notebook/tests/test_paths.py b/notebook/tests/test_paths.py index 33f44afe4d..2cc61298b3 100644 --- a/notebook/tests/test_paths.py +++ b/notebook/tests/test_paths.py @@ -6,7 +6,7 @@ from .launchnotebook import NotebookTestBase # build regexps that tornado uses: -path_pat = re.compile('^' + '/x%s' % path_regex + '$') +path_pat = re.compile(r'^' + r'/x%s' % path_regex + r'$') def test_path_regex(): diff --git a/setup.py b/setup.py index 676c5763cd..fc1a022f5e 100755 --- a/setup.py +++ b/setup.py @@ -115,7 +115,7 @@ 'prometheus_client' ], extras_require = { - 'test': ['pytest', 'coverage', 'requests', + 'test': ['pytest', 'coverage', 'requests', 'nose', 'nbval', 'selenium', 'pytest', 'pytest-cov'], 'docs': ['sphinx', 'nbsphinx', 'sphinxcontrib_github_alt', 'sphinx_rtd_theme'], 'test:sys_platform != "win32"': ['requests-unixsocket'],