diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1bad3e2..66b2e11 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,25 +3,66 @@
name: continuous-integration
-on: [push, pull_request]
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+env:
+ FORCE_COLOR: '1'
+
+# https://docs.github.com/en/actions/using-jobs/using-concurrency
+concurrency:
+ # only cancel in-progress jobs or runs for the current workflow - matches against branch & tags
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
- pre-commit:
+ test-notebooks:
+
+ strategy:
+ matrix:
+ browser: [Chrome, Firefox]
+ fail-fast: false
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- - uses: actions/checkout@v4
- - name: Setup Python
+ - name: Check out app
+ uses: actions/checkout@v4
+
+ - name: Set up Python
uses: actions/setup-python@v5
with:
- python-version: 3.11
+ python-version: '3.11'
- - name: Install dependencies
- run: |
- pip install .[dev]
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v3
+ with:
+ version: 0.4.20
- - name: Run pre-commit
- run: pre-commit run --all-files || ( git status --short ; git diff ; exit 1 )
+ - name: Install package test dependencies
+ # Notebook tests happen in the container, here we only need to install
+ # the pytest-docker dependency. Unfortunately, uv/pip does not allow to
+ # only install [dev] dependencies so we end up installing all the rest as well.
+ run: uv pip install --system .[dev]
+
+ - name: Set jupyter token env
+ run: echo "JUPYTER_TOKEN=$(openssl rand -hex 32)" >> $GITHUB_ENV
+
+ - name: Run pytest
+ run: pytest -v --driver ${{ matrix.browser }} tests_notebooks
+ env:
+ TAG: edge
+
+ - name: Upload screenshots as artifacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: Screenshots-${{ matrix.browser }}
+ path: screenshots/
+ if-no-files-found: error
diff --git a/home/utils.py b/home/utils.py
index 6bdfe49..c365b1f 100644
--- a/home/utils.py
+++ b/home/utils.py
@@ -32,7 +32,7 @@ def load_start_py(name):
)
except TypeError:
return mod.get_start_widget(appbase=appbase, jupbase=jupbase)
- except Exception: # pylint: disable=broad-except
+ except Exception:
return ipw.HTML(f"
{sys.exc_info()}
")
@@ -51,7 +51,7 @@ def load_start_md(name):
html = html.replace("=3.9
dev =
bumpver==2022.1118
pre-commit==3.6.0
+ pytest~=8.3.0
+ pytest-docker~=3.1.0
+ pytest-selenium~=4.1.0
+ selenium~=4.23.0
[flake8]
ignore =
diff --git a/single_app.ipynb b/single_app.ipynb
index 298e19f..c38cd6a 100644
--- a/single_app.ipynb
+++ b/single_app.ipynb
@@ -41,9 +41,8 @@
"url = urlparse.urlsplit(jupyter_notebook_url) # noqa: F821\n",
"try:\n",
" name = urlparse.parse_qs(url.query)[\"app\"][0]\n",
- "except KeyError:\n",
- " raise Exception(\"No app specified\") # noqa: TRY002\n",
- " exit()"
+ "except KeyError as e:\n",
+ " raise ValueError(\"No app specified\") from e"
]
},
{
diff --git a/tests/test_manage_app.py b/tests/test_manage_app.py
deleted file mode 100755
index cdc6db7..0000000
--- a/tests/test_manage_app.py
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/env python
-from selenium.webdriver.common.by import By
-
-
-def test_uninstall_install_widgets_base(selenium, url):
- selenium.get(url("apps/apps/home/single_app.ipynb?app=aiidalab-widgets-base"))
- selenium.set_window_size(1440, 828)
- selenium.find_element(By.XPATH, "//button[contains(.,'Uninstall')]").click()
- selenium.get_screenshot_as_file(
- "screenshots/manage-app-aiidalab-widgets-base-uninstalled.png"
- )
- selenium.find_element(By.XPATH, "//button[contains(.,'Install')]").click()
- selenium.get_screenshot_as_file(
- "screenshots/manage-app-aiidalab-widgets-base-installed.png"
- )
diff --git a/tests_notebooks/conftest.py b/tests_notebooks/conftest.py
new file mode 100644
index 0000000..379aa02
--- /dev/null
+++ b/tests_notebooks/conftest.py
@@ -0,0 +1,130 @@
+import os
+from pathlib import Path
+from time import sleep
+from urllib.parse import urljoin
+
+import pytest
+import requests
+import selenium.webdriver.support.expected_conditions as ec
+from requests.exceptions import ConnectionError
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.wait import WebDriverWait
+
+
+def is_responsive(url):
+ try:
+ response = requests.get(url)
+ if response.status_code == 200:
+ return True
+ except ConnectionError:
+ return False
+
+
+@pytest.fixture(scope="session")
+def screenshot_dir():
+ sdir = Path.joinpath(Path.cwd(), "screenshots")
+ try:
+ os.mkdir(sdir)
+ except FileExistsError:
+ pass
+ return sdir
+
+
+@pytest.fixture(scope="session")
+def docker_compose_file(pytestconfig):
+ return str(Path(pytestconfig.rootdir) / "tests_notebooks" / "docker-compose.yml")
+
+
+@pytest.fixture(scope="session")
+def docker_compose(docker_services):
+ return docker_services._docker_compose
+
+
+@pytest.fixture(scope="session")
+def aiidalab_exec(docker_compose):
+ def execute(command, user=None, **kwargs):
+ workdir = "/home/jovyan/apps/home"
+ if user:
+ command = f"exec --workdir {workdir} -T --user={user} aiidalab {command}"
+ else:
+ command = f"exec --workdir {workdir} -T aiidalab {command}"
+
+ return docker_compose.execute(command, **kwargs)
+
+ return execute
+
+
+@pytest.fixture(scope="session", autouse=True)
+def notebook_service(docker_ip, docker_services, aiidalab_exec):
+ """Ensure that HTTP service is up and responsive."""
+ # Directory ~/apps/home/ is mounted by docker,
+ # make it writeable for jovyan user, needed for `pip install`
+ aiidalab_exec("chmod -R a+rw /home/jovyan/apps/home", user="root")
+
+ aiidalab_exec("pip install --no-cache-dir .")
+
+ # `port_for` takes a container port and returns the corresponding host port
+ port = docker_services.port_for("aiidalab", 8888)
+ url = f"http://{docker_ip}:{port}"
+ token = os.environ["JUPYTER_TOKEN"]
+ docker_services.wait_until_responsive(
+ timeout=30.0, pause=0.1, check=lambda: is_responsive(url)
+ )
+ return url, token
+
+
+@pytest.fixture(scope="function")
+def selenium_driver(selenium, notebook_service):
+ def _selenium_driver(nb_path, url_params=None):
+ url, token = notebook_service
+ url_with_token = urljoin(url, f"apps/apps/home/{nb_path}?token={token}")
+ if url_params is not None:
+ for key, value in url_params.items():
+ url_with_token += f"&{key}={value}"
+ selenium.get(f"{url_with_token}")
+ # By default, let's allow selenium functions to retry for 60s
+ # till a given element is loaded, see:
+ # https://selenium-python.readthedocs.io/waits.html#implicit-waits
+ selenium.implicitly_wait(90)
+ window_width = 800
+ window_height = 600
+ selenium.set_window_size(window_width, window_height)
+
+ selenium.find_element(By.ID, "ipython-main-app")
+ selenium.find_element(By.ID, "notebook-container")
+ selenium.find_element(By.ID, "appmode-busy")
+ # We wait until the appmode spinner disappears. However,
+ # this does not seem to be fully robust, as the spinner might flash
+ # while the page is still loading. So we add explicit sleep here as well.
+ WebDriverWait(selenium, 240).until(
+ ec.invisibility_of_element((By.ID, "appmode-busy"))
+ )
+ sleep(5)
+
+ return selenium
+
+ return _selenium_driver
+
+
+@pytest.fixture
+def final_screenshot(request, screenshot_dir, selenium):
+ """Take screenshot at the end of the test.
+ Screenshot name is generated from the test function name
+ by stripping the 'test_' prefix
+ """
+ screenshot_name = f"{request.function.__name__[5:]}.png"
+ screenshot_path = Path.joinpath(screenshot_dir, screenshot_name)
+ yield
+ selenium.get_screenshot_as_file(screenshot_path)
+
+
+@pytest.fixture
+def firefox_options(firefox_options):
+ firefox_options.add_argument("--headless")
+ return firefox_options
+
+
+@pytest.fixture
+def chrome_options(chrome_options):
+ chrome_options.add_argument("--headless")
+ return chrome_options
diff --git a/tests_notebooks/docker-compose.yml b/tests_notebooks/docker-compose.yml
new file mode 100644
index 0000000..a39901a
--- /dev/null
+++ b/tests_notebooks/docker-compose.yml
@@ -0,0 +1,16 @@
+---
+services:
+
+ aiidalab:
+ image: ghcr.io/aiidalab/full-stack:${TAG:-latest}
+ environment:
+ RMQHOST: messaging
+ TZ: Europe/Zurich
+ DOCKER_STACKS_JUPYTER_CMD: notebook
+ SETUP_DEFAULT_AIIDA_PROFILE: 'true'
+ AIIDALAB_DEFAULT_APPS: ''
+ JUPYTER_TOKEN: ${JUPYTER_TOKEN}
+ volumes:
+ - ..:/home/jovyan/apps/home
+ ports:
+ - 8998:8888
diff --git a/tests_notebooks/test_manage_app.py b/tests_notebooks/test_manage_app.py
new file mode 100755
index 0000000..115567b
--- /dev/null
+++ b/tests_notebooks/test_manage_app.py
@@ -0,0 +1,9 @@
+from selenium.webdriver.common.by import By
+
+
+def test_single_app(selenium_driver, final_screenshot):
+ url_params = {"app": "aiidalab-widgets-base"}
+ selenium = selenium_driver("single_app.ipynb", url_params)
+ selenium.set_window_size(1000, 1100)
+ selenium.find_element(By.XPATH, "//button[contains(.,'Uninstall')]")
+ selenium.find_element(By.XPATH, "//button[contains(.,'Install')]")
diff --git a/tests/test_start.py b/tests_notebooks/test_start.py
similarity index 72%
rename from tests/test_start.py
rename to tests_notebooks/test_start.py
index 44e23a2..311f2cd 100755
--- a/tests/test_start.py
+++ b/tests_notebooks/test_start.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
import time
from contextlib import contextmanager
@@ -14,13 +13,14 @@ def get_new_windows(selenium, timeout=2):
handles.update(set(selenium.window_handles).difference(wh_before))
-def test_click_appstore(selenium, url):
- selenium.get(url("apps/apps/home/start.ipynb"))
+def test_click_appstore(selenium_driver, final_screenshot):
+ selenium = selenium_driver("start.ipynb")
with get_new_windows(selenium) as handles:
selenium.find_element(By.CSS_SELECTOR, ".fa-puzzle-piece").click()
assert len(handles) == 1
selenium.switch_to.window(handles.pop())
time.sleep(5)
+ selenium.set_window_size(1000, 1100)
dropdown = selenium.find_element(
By.XPATH,
"//div[@id='notebook-container']/div[5]/div[2]/div[2]/div/div[3]/div/div[2]/div/select",
@@ -29,23 +29,24 @@ def test_click_appstore(selenium, url):
selenium.find_element(By.CSS_SELECTOR, ".widget-button:nth-child(1)").click()
selenium.find_element(By.CSS_SELECTOR, ".widget-html-content > h1").click()
time.sleep(5)
- selenium.get_screenshot_as_file("screenshots/app-store.png")
-def test_click_help(selenium, url):
- selenium.get(url("apps/apps/home/start.ipynb"))
+def test_click_help(selenium_driver, final_screenshot):
+ selenium = selenium_driver("start.ipynb")
selenium.set_window_size(1200, 941)
with get_new_windows(selenium) as handles:
selenium.find_element(By.CSS_SELECTOR, ".fa-question").click()
assert len(handles) == 1
+ # Redirect to https://aiidalab.readthedocs.io
selenium.switch_to.window(handles.pop())
- selenium.find_element(By.CSS_SELECTOR, ".mr-md-2").click()
- selenium.get_screenshot_as_file("screenshots/help.png")
+ # TODO: Instead of selecting a specific element on the Docs page,
+ # validate the URL.
+ # selenium.find_element(By.CSS_SELECTOR, ".mr-md-2").click()
-def test_click_filemanager(selenium, url):
- selenium.get(url("apps/apps/home/start.ipynb"))
- selenium.set_window_size(1200, 941)
+def test_click_filemanager(selenium_driver, final_screenshot):
+ selenium = selenium_driver("start.ipynb")
+ selenium.set_window_size(1000, 941)
with get_new_windows(selenium) as handles:
selenium.find_element(By.CSS_SELECTOR, ".fa-file-text-o").click()
assert len(handles) == 1
@@ -53,4 +54,3 @@ def test_click_filemanager(selenium, url):
selenium.find_element(By.LINK_TEXT, "Running").click()
selenium.find_element(By.LINK_TEXT, "Clusters").click()
selenium.find_element(By.LINK_TEXT, "Files").click()
- selenium.get_screenshot_as_file("screenshots/file-manager.png")
diff --git a/tests/test_terminal.py b/tests_notebooks/test_terminal.py
similarity index 75%
rename from tests/test_terminal.py
rename to tests_notebooks/test_terminal.py
index ef19180..b1ae190 100755
--- a/tests/test_terminal.py
+++ b/tests_notebooks/test_terminal.py
@@ -1,12 +1,11 @@
-#!/usr/bin/env python
from time import sleep
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
-def test_terminal(selenium, url):
- selenium.get(url("apps/apps/home/start.ipynb"))
+def test_terminal(selenium_driver, final_screenshot):
+ selenium = selenium_driver("start.ipynb")
selenium.set_window_size(1575, 907)
selenium.find_element(By.CSS_SELECTOR, ".fa-terminal").click()
page = selenium.window_handles[-1]
@@ -19,5 +18,4 @@ def test_terminal(selenium, url):
selenium.find_element(By.CSS_SELECTOR, ".xterm-helper-textarea").send_keys(
Keys.ENTER
)
- sleep(1)
- selenium.get_screenshot_as_file("screenshots/aiidalab-terminal.png")
+ sleep(2)