diff --git a/.github/workflows/tests-lab-4.yml b/.github/workflows/tests-lab-4.yml new file mode 100644 index 0000000..17e58f7 --- /dev/null +++ b/.github/workflows/tests-lab-4.yml @@ -0,0 +1,37 @@ +name: Tests - lab 4 + +on: + push: + branches: [main] + pull_request: + # Check all PR + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-22.04 + python-version: "3.11" + + steps: + - uses: actions/checkout@v3 + - name: Install Firefox + uses: browser-actions/setup-firefox@latest + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: pip install tox + + - name: run Python tests + run: tox -e tests-lab-4 + + - name: run Python tests for coverage + run: tox -e coverage + - uses: codecov/codecov-action@v3 + with: + files: coverage.xml + verbose: true diff --git a/pyproject.toml b/pyproject.toml index a4da607..d5a04e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ dependencies = [ "ipywidgets>=8.0.0", "numpy<2.0.0", - "widget_code_input<4.0.0", + "widget_code_input>=4.0.13", "matplotlib", "termcolor" ] diff --git a/src/scwidgets/cue/_widget_cue_figure.py b/src/scwidgets/cue/_widget_cue_figure.py index 25af950..232d04b 100644 --- a/src/scwidgets/cue/_widget_cue_figure.py +++ b/src/scwidgets/cue/_widget_cue_figure.py @@ -65,7 +65,12 @@ def __init__( # we close the figure so the figure is only contained in this widget # and not shown using plt.show() plt.close(self.figure) - elif matplotlib.backends.backend == "module://ipympl.backend_nbagg": + elif ( + matplotlib.backends.backend == "module://ipympl.backend_nbagg" + or matplotlib.backends.backend == "widget" + ): + # jupyter lab 3 uses "module://ipympl.backend_nbagg" + # jupyter lab 4 uses "widget" with self: self.figure.canvas.show() else: @@ -83,7 +88,12 @@ def clear_display(self, wait=False): if matplotlib.backends.backend == "module://matplotlib_inline.backend_inline": self.clear_figure() self.clear_output(wait=wait) - elif matplotlib.backends.backend == "module://ipympl.backend_nbagg": + elif ( + matplotlib.backends.backend == "module://ipympl.backend_nbagg" + or matplotlib.backends.backend == "widget" + ): + # jupyter lab 3 uses "module://ipympl.backend_nbagg" + # jupyter lab 4 uses "widget" self.clear_figure() if not (wait): self.figure.canvas.draw_idle() @@ -100,7 +110,12 @@ def draw_display(self): if matplotlib.backends.backend == "module://matplotlib_inline.backend_inline": with self: display(self.figure) - elif matplotlib.backends.backend == "module://ipympl.backend_nbagg": + elif ( + matplotlib.backends.backend == "module://ipympl.backend_nbagg" + or matplotlib.backends.backend == "widget" + ): + # jupyter lab 3 uses "module://ipympl.backend_nbagg" + # jupyter lab 4 uses "widget" self.figure.canvas.draw_idle() self.figure.canvas.flush_events() else: diff --git a/tests/conftest.py b/tests/conftest.py index 82feb9b..137662e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from urllib.parse import urljoin import pytest +from packaging.version import Version from selenium.common.exceptions import StaleElementReferenceException from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions @@ -17,7 +18,7 @@ JUPYTER_VERSION = None -def get_jupyter_version() -> str: +def get_jupyter_version() -> Version: """ Function so we can update the jupyter version during initialization and use it in other files @@ -45,7 +46,7 @@ def notebook_service(): ["jupyter", f"{JUPYTER_TYPE}", "--version"] ) # convert to string - JUPYTER_VERSION = jupyter_version.decode().replace("\n", "") + JUPYTER_VERSION = Version(jupyter_version.decode().replace("\n", "")) jupyter_process = subprocess.Popen( [ @@ -106,7 +107,7 @@ def _selenium_driver(nb_path): # jupyter lab < 4 if JUPYTER_TYPE == "lab": - if get_jupyter_version() < "4.0.0": + if get_jupyter_version() < Version("4.0.0"): restart_kernel_button_class_name = ( "bp3-button.bp3-minimal.jp-ToolbarButtonComponent.minimal.jp-Button" ) @@ -114,9 +115,12 @@ def _selenium_driver(nb_path): "Restart Kernel and Run All Cells…" ) else: - raise ValueError("jupyter lab > 4.0.0 is not supported.") + restart_kernel_button_class_name = "jp-ToolbarButtonComponent" + restart_kernel_button_title_attribute = ( + "Restart the kernel and run all cells" + ) elif JUPYTER_TYPE == "notebook": - if get_jupyter_version() < "7.0.0": + if get_jupyter_version() < Version("7.0.0"): restart_kernel_button_class_name = "btn.btn-default" restart_kernel_button_title_attribute = ( "restart the kernel, then re-run the whole notebook (with dialog)" @@ -169,15 +173,18 @@ def _selenium_driver(nb_path): # ------------------------------- if JUPYTER_TYPE == "lab": - if get_jupyter_version() < "4.0.0": + if get_jupyter_version() < Version("4.0.0"): restart_button_class_name = ( "jp-Dialog-button.jp-mod-accept.jp-mod-warn.jp-mod-styled" ) restart_button_text = "Restart" else: - raise ValueError("jupyter lab > 4.0.0 is not supported.") + restart_button_class_name = ( + "jp-Dialog-button.jp-mod-accept.jp-mod-warn.jp-mod-styled" + ) + restart_button_text = "Restart" elif JUPYTER_TYPE == "notebook": - if get_jupyter_version() < "7.0.0": + if get_jupyter_version() < Version("7.0.0"): restart_button_class_name = "btn.btn-default.btn-sm.btn-danger" restart_button_text = "Restart and Run All Cells" else: diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 5131345..179a264 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -8,6 +8,8 @@ import pytest import requests from imageio.v3 import imread +from packaging.version import Version +from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webelement import WebElement @@ -41,45 +43,6 @@ def crop_const_color_borders(image: np.ndarray, const_color: int = 255): return image[i1:i2, j1:j2, :] -if JUPYTER_TYPE == "notebook": - BUTTON_CLASS_NAME = "lm-Widget.jupyter-widgets.jupyter-button.widget-button" - OUTPUT_CLASS_NAME = "lm-Widget.jp-RenderedText.jp-mod-trusted.jp-OutputArea-output" - TEXT_INPUT_CLASS_NAME = "widget-input" - CODE_MIRROR_CLASS_NAME = "CodeMirror-code" - MATPLOTLIB_CANVAS_CLASS_NAME = "jupyter-widgets.jupyter-matplotlib-canvas-container" - CUE_BOX_CLASS_NAME = ( - "lm-Widget.lm-Panel.jupyter-widgets.widget-container" - ".widget-box.widget-vbox.scwidget-cue-box" - ) -elif JUPYTER_TYPE == "lab": - BUTTON_CLASS_NAME = ( - "lm-Widget.p-Widget.jupyter-widgets.jupyter-button.widget-button" - ) - OUTPUT_CLASS_NAME = ( - "lm-Widget.p-Widget.jp-RenderedText.jp-mod-trusted.jp-OutputArea-output" - ) - TEXT_INPUT_CLASS_NAME = "widget-input" - CODE_MIRROR_CLASS_NAME = "CodeMirror-code" - - MATPLOTLIB_CANVAS_CLASS_NAME = "jupyter-widgets.jupyter-matplotlib-canvas-container" - CUE_BOX_CLASS_NAME = ( - "lm-Widget.p-Widget.lm-Panel.p-Panel.jupyter-widgets." - "widget-container.widget-box.widget-vbox.scwidget-cue-box" - ) -else: - raise ValueError( - f"Tests do not support jupyter type {JUPYTER_TYPE!r}. Please use 'notebook' or" - " 'lab'." - ) - -CUED_CUE_BOX_CLASS_NAME = f"{CUE_BOX_CLASS_NAME}.scwidget-cue-box--cue" - -RESET_CUE_BUTTON_CLASS_NAME = f"{BUTTON_CLASS_NAME}.scwidget-reset-cue-button" -CUED_RESET_CUE_BUTTON_CLASS_NAME = ( - f"{RESET_CUE_BUTTON_CLASS_NAME}.scwidget-reset-cue-button--cue" -) - - def cue_box_class_name(cue_type: str, cued: bool): class_name = CUED_CUE_BOX_CLASS_NAME if cued else CUE_BOX_CLASS_NAME if cue_type is None: @@ -116,18 +79,49 @@ def scwidget_reset_cue_button_class_name(cue_type: str, cued: bool): return class_name.replace("reset-cue-button", f"{cue_type}-reset-cue-button") -def get_nb_cells(driver) -> List[WebElement]: +class NotebookCellList(list): """ - Filters out empty cells + List of notebook cells that scrolls them into the view when accessing it. When a + cell is accessed it always goes to the top to scroll down cell by cell. We can only + scroll to an element if it is partially visible, so this method works as long as a + cell is not larger than the view. We need to put the cells into the view because the + content of the cells in lab 4 is not loaded otherwise. :param driver: see conftest.py selenium_driver function """ - # Each cell of the notebook, the cell number can be retrieved from the - # attribute "data-windowed-list-index" - nb_cells = driver.find_elements( - By.CLASS_NAME, "lm-Widget.jp-Cell.jp-CodeCell.jp-Notebook-cell" - ) - return [nb_cell for nb_cell in nb_cells if nb_cell.text != ""] + + def __init__(self, driver): + self._driver = driver + + nb_cells = driver.find_elements( + By.CLASS_NAME, "lm-Widget.jp-Cell.jp-CodeCell.jp-Notebook-cell" + ) + # we scroll through the notebook and remove the cells that are empty + ActionChains(driver).send_keys(Keys.HOME).perform() + time.sleep(0.1) + nb_cells_non_empty = [] + for nb_cell in nb_cells: + driver.execute_script("arguments[0].scrollIntoView();", nb_cell) + time.sleep(0.05) + if nb_cell.text != "": + nb_cells_non_empty.append(nb_cell) + + super().__init__(nb_cells_non_empty) + + def __getitem__(self, key): + # have to retrieve from scratch as positions may have changed + ActionChains(self._driver).send_keys(Keys.HOME).perform() + time.sleep(0.1) + for i in range(key): + self._driver.execute_script( + "arguments[0].scrollIntoView();", super().__getitem__(i) + ) + time.sleep(0.05) + + nb_cell = super().__getitem__(key) + self._driver.execute_script("arguments[0].scrollIntoView();", nb_cell) + time.sleep(0.05) + return nb_cell ######### @@ -156,6 +150,82 @@ def test_notebook_running(notebook_service): assert response.status_code == 200 +def test_setup_globals(): + from .conftest import JUPYTER_VERSION + + # black formats this into one line which causes an error in the linter. + # fmt: off + global BUTTON_CLASS_NAME, OUTPUT_CLASS_NAME, TEXT_INPUT_CLASS_NAME, \ + CODE_MIRROR_CLASS_NAME, MATPLOTLIB_CANVAS_CLASS_NAME, CUE_BOX_CLASS_NAME, \ + PRIVACY_BUTTON + global CUED_CUE_BOX_CLASS_NAME, RESET_CUE_BUTTON_CLASS_NAME, \ + CUED_RESET_CUE_BUTTON_CLASS_NAME + # fmt: on + + if JUPYTER_TYPE == "notebook" and JUPYTER_VERSION >= Version("7.0.0"): + BUTTON_CLASS_NAME = "lm-Widget.jupyter-widgets.jupyter-button.widget-button" + OUTPUT_CLASS_NAME = ( + "lm-Widget.jp-RenderedText.jp-mod-trusted.jp-OutputArea-output" + ) + TEXT_INPUT_CLASS_NAME = "widget-input" + CODE_MIRROR_CLASS_NAME = "cm-content" + MATPLOTLIB_CANVAS_CLASS_NAME = ( + "jupyter-widgets.jupyter-matplotlib-canvas-container" + ) + CUE_BOX_CLASS_NAME = ( + "lm-Widget.lm-Panel.jupyter-widgets.widget-container" + ".widget-box.widget-vbox.scwidget-cue-box" + ) + PRIVACY_BUTTON = "bp3-button.bp3-small.jp-toast-button.jp-Button" + elif JUPYTER_TYPE == "lab" and JUPYTER_VERSION < Version("4.0.0"): + BUTTON_CLASS_NAME = ( + "lm-Widget.p-Widget.jupyter-widgets.jupyter-button.widget-button" + ) + OUTPUT_CLASS_NAME = ( + "lm-Widget.p-Widget.jp-RenderedText.jp-mod-trusted.jp-OutputArea-output" + ) + TEXT_INPUT_CLASS_NAME = "widget-input" + CODE_MIRROR_CLASS_NAME = "cm-content" + + MATPLOTLIB_CANVAS_CLASS_NAME = ( + "jupyter-widgets.jupyter-matplotlib-canvas-container" + ) + CUE_BOX_CLASS_NAME = ( + "lm-Widget.p-Widget.lm-Panel.p-Panel.jupyter-widgets." + "widget-container.widget-box.widget-vbox.scwidget-cue-box" + ) + PRIVACY_BUTTON = "bp3-button.bp3-small.jp-toast-button.jp-Button" + elif JUPYTER_TYPE == "lab" and JUPYTER_VERSION >= Version("4.0.0"): + BUTTON_CLASS_NAME = "lm-Widget.jupyter-widgets.jupyter-button.widget-button" + OUTPUT_CLASS_NAME = ( + "lm-Widget.jp-RenderedText.jp-mod-trusted.jp-OutputArea-output" + ) + + TEXT_INPUT_CLASS_NAME = "widget-input" + CODE_MIRROR_CLASS_NAME = "cm-content" + + MATPLOTLIB_CANVAS_CLASS_NAME = ( + "jupyter-widgets.jupyter-matplotlib-canvas-container" + ) + CUE_BOX_CLASS_NAME = ( + "lm-Widget.lm-Panel.jupyter-widgets.widget-container." + "widget-box.widget-vbox.scwidget-cue-box" + ) + PRIVACY_BUTTON = "jp-toast-button.jp-mod-small.jp-Button" + else: + raise ValueError( + f"Tests do not support jupyter type {JUPYTER_TYPE!r} for version" + f"{JUPYTER_VERSION!r}." + ) + + CUED_CUE_BOX_CLASS_NAME = f"{CUE_BOX_CLASS_NAME}.scwidget-cue-box--cue" + + RESET_CUE_BUTTON_CLASS_NAME = f"{BUTTON_CLASS_NAME}.scwidget-reset-cue-button" + CUED_RESET_CUE_BUTTON_CLASS_NAME = ( + f"{RESET_CUE_BUTTON_CLASS_NAME}.scwidget-reset-cue-button--cue" + ) + + def test_privacy_policy(selenium_driver): """ The first time jupyter lab is started on a fresh installation a privacy popup @@ -167,9 +237,7 @@ def test_privacy_policy(selenium_driver): driver = selenium_driver("tests/notebooks/widget_answers.ipynb") # we search for the button to appear so we can be sure that the privacy window # appeared - privacy_buttons = driver.find_elements( - By.CLASS_NAME, "bp3-button.bp3-small.jp-toast-button.jp-Button" - ) + privacy_buttons = driver.find_elements(By.CLASS_NAME, PRIVACY_BUTTON) yes_button = None for button in privacy_buttons: if button.text == "Yes": @@ -209,7 +277,7 @@ def test_widget_answer(self, selenium_driver): driver = selenium_driver("tests/notebooks/widget_answers.ipynb") - nb_cells = get_nb_cells(driver) + nb_cells = NotebookCellList(driver) # Test 1: # ------- @@ -361,7 +429,13 @@ def test_widget_answer(self, selenium_driver): # WebDriverWait(driver, 1).until( expected_conditions.element_to_be_clickable(save_button) - ).click() + ) + from .conftest import JUPYTER_VERSION + + if JUPYTER_TYPE == "lab" and JUPYTER_VERSION >= Version("4.0.0"): + # button is obscured so we need to click with action on the cell + ActionChains(driver).click(nb_cell).perform() + save_button.click() # wait for uncued box cue_box = nb_cell.find_element(By.CLASS_NAME, cue_box_class_name("save", False)) assert "--cued" not in cue_box.get_attribute("class") @@ -505,6 +579,7 @@ def test_widget_answer(self, selenium_driver): ("tests/notebooks/widget_cue_figure-inline.ipynb", "inline"), ], ) +@pytest.mark.matplotlib def test_widget_figure(selenium_driver, nb_filename, mpl_backend): """ We separate the widget figure tests for different backends to different files @@ -516,7 +591,7 @@ def test_widget_figure(selenium_driver, nb_filename, mpl_backend): # TODO for inline i need to get the image directly from the panel driver = selenium_driver(nb_filename) - nb_cells = get_nb_cells(driver) + nb_cells = NotebookCellList(driver) if "inline" == mpl_backend: by_type = By.TAG_NAME @@ -640,7 +715,7 @@ def test_widgets_cue(selenium_driver): """ driver = selenium_driver("tests/notebooks/widgets_cue.ipynb") - nb_cells = get_nb_cells(driver) + nb_cells = NotebookCellList(driver) # Test 1: # ------- # Check if CueBox shows cue when changed @@ -857,7 +932,7 @@ def test_widget_check_registry(selenium_driver): """ driver = selenium_driver("tests/notebooks/widget_check_registry.ipynb") - nb_cells = get_nb_cells(driver) + nb_cells = NotebookCellList(driver) # Test 1: # ------- @@ -893,7 +968,13 @@ def test_button_clicks( WebDriverWait(driver, 5).until( expected_conditions.element_to_be_clickable(check_all_widgets_button) - ).click() + ) + from .conftest import JUPYTER_VERSION + + if JUPYTER_TYPE == "lab" and JUPYTER_VERSION >= Version("4.0.0"): + # button is obscured so we need to click with action on the cell + ActionChains(driver).click(nb_cell).perform() + check_all_widgets_button.click() time.sleep(0.1) outputs = nb_cell.find_elements(By.CLASS_NAME, OUTPUT_CLASS_NAME) assert ( @@ -985,7 +1066,7 @@ def test_widgets_code(selenium_driver): """ driver = selenium_driver("tests/notebooks/widget_code_exercise.ipynb") - nb_cells = get_nb_cells(driver) + nb_cells = NotebookCellList(driver) # Test 1: # ------- WebDriverWait(driver, 5).until( diff --git a/tox.ini b/tox.ini index a4f101c..64ac816 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,7 @@ setenv = JUPYTER_DATA_DIR={envdir}/share/jupyter deps = pytest<8.0.0 + pytest-rerunfailures pytest-html<4.0.0, # selenium juypter notebook tests jupyterlab==3.6.5 @@ -41,7 +42,40 @@ deps = commands = # converts the python files to ipython notebooks jupytext tests/notebooks/*.py --to ipynb - pytest {posargs:-v} --driver Firefox + pytest {posargs:-v} --reruns 2 --driver Firefox + +[testenv:tests-lab-4] +description = + Tests with jupyter lab version >= 4 +setenv = + # this is needed to run selenium on a machine without display to do CI + SELENIUM_FIREFOX_DRIVER_ARGS = {env:SELENIUM_FIREFOX_DRIVER_ARGS:--headless} + JUPYTER_TYPE = lab + # use the jupyter config in the tox environment + # otherwise the users config is used + JUPYTER_CONFIG_DIR={envdir}/etc/jupyter + JUPYTER_DATA_DIR={envdir}/share/jupyter +deps = + pytest<8.0.0 + pytest-rerunfailures + pytest-html<4.0.0, + # selenium juypter notebook tests + jupyterlab>=4.0.0 + # fixing selenium version to have backwards-compatibility with pytest-selenium + # see https://github.com/robotframework/SeleniumLibrary/issues/1835#issuecomment-1581426365 + selenium==4.9.0 + pytest-selenium + jupytext==1.15.0 + imageio + # we fix matplotlib for consistent image tests + matplotlib==3.7.2 + numpy<2.0.0 + scikit-image + ipympl +commands = + # converts the python files to ipython notebooks + jupytext tests/notebooks/*.py --to ipynb + pytest {posargs:-v} -m "not matplotlib" --reruns 2 --driver Firefox [testenv:coverage] # We do coverage in a separate environment that skips the selenium tests but