diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml new file mode 100644 index 000000000..db22f69c0 --- /dev/null +++ b/.github/workflows/playwright.yaml @@ -0,0 +1,89 @@ +name: Playwright Tests + +on: + pull_request: + paths-ignore: + - "**.md" + - "**.rst" + - "docs/**" + - "examples/**" + - ".github/workflows/**" + - "!.github/workflows/playwright.yaml" + push: + paths-ignore: + - "**.md" + - "**.rst" + - "docs/**" + - "examples/**" + - ".github/workflows/**" + - "!.github/workflows/playwright.yaml" + branches-ignore: + - "dependabot/**" + - "pre-commit-ci-update-config" + - "update-*" + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-22.04 + timeout-minutes: 10 + + permissions: + contents: read + env: + GITHUB_ACCESS_TOKEN: "${{ secrets.github_token }}" + + steps: + - uses: actions/checkout@v4 + + - name: Setup OS level dependencies + run: | + sudo apt-get update + sudo apt-get install --yes \ + build-essential \ + curl \ + libcurl4-openssl-dev \ + libssl-dev + + - uses: actions/setup-node@v4 + id: setup-node + with: + node-version: "22" + + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: node-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('**/package.json') }}-${{ github.job }} + + - name: Run webpack to build static assets + run: | + npm install + npm run webpack + + - uses: actions/setup-python@v5 + id: setup-python + with: + python-version: "3.12" + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/*requirements.txt') }}-${{ github.job }} + + - name: Setup Python package dependencies + run: | + pip install --no-binary pycurl -r dev-requirements.txt + pip install -e . + + - name: Install playwright browser + run: | + playwright install firefox + + - name: Run playwright tests + run: | + py.test binderhub/tests/test_playwright.py + + # Upload test coverage info to codecov + - uses: codecov/codecov-action@v5 diff --git a/binderhub/tests/conftest.py b/binderhub/tests/conftest.py index 2252246ab..58fba0b67 100644 --- a/binderhub/tests/conftest.py +++ b/binderhub/tests/conftest.py @@ -12,6 +12,7 @@ import kubernetes.client import kubernetes.config +import nest_asyncio import pytest import requests from tornado.httpclient import AsyncHTTPClient @@ -42,8 +43,12 @@ def pytest_configure(config): - """This function has meaning to pytest, for more information, see: - https://docs.pytest.org/en/stable/reference.html#pytest.hookspec.pytest_configure + """ + Configure plugins and custom markers + + This function is called by pytest after command line arguments have + been parsed. See https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_configure + for more information. """ # register our custom markers config.addinivalue_line( @@ -60,6 +65,9 @@ def pytest_configure(config): "helm: mark test to only run when BinderHub is launched with our k8s-binderhub test config.", ) + # Required for playwright to be run from within pytest + nest_asyncio.apply() + def pytest_runtest_setup(item): is_helm_test = any(mark for mark in item.iter_markers(name="helm")) diff --git a/binderhub/tests/test_main.py b/binderhub/tests/test_main.py index b19761d08..12981584d 100644 --- a/binderhub/tests/test_main.py +++ b/binderhub/tests/test_main.py @@ -60,29 +60,6 @@ def _resolve_url(page_url, url): return f"{parsed.scheme}://{parsed.netloc}{path}{url}" -@pytest.mark.remote -async def test_main_page(app): - """Check the main page and any links on it""" - r = await async_requests.get(app.url) - assert r.status_code == 200 - soup = BeautifulSoup(r.text, "html5lib") - - # check src links (style, images) - for el in soup.find_all(src=True): - url = _resolve_url(app.url, el["src"]) - r = await async_requests.get(url) - assert r.status_code == 200, f"{r.status_code} {url}" - - # check hrefs - for el in soup.find_all(href=True): - href = el["href"] - if href.startswith("#"): - continue - url = _resolve_url(app.url, href) - r = await async_requests.get(url) - assert r.status_code == 200, f"{r.status_code} {url}" - - @pytest.mark.remote @pytest.mark.helm async def test_custom_template(app): @@ -92,15 +69,6 @@ async def test_custom_template(app): assert "test-template" in r.text -@pytest.mark.remote -async def test_about_handler(app): - # Check that the about page loads - r = await async_requests.get(app.url + "/about") - assert r.status_code == 200 - assert "This website is powered by" in r.text - assert binder_version.split("+")[0] in r.text - - @pytest.mark.remote async def test_versions_handler(app): # Check that the about page loads @@ -121,65 +89,6 @@ async def test_versions_handler(app): assert data["binderhub"].split("+")[0] == binder_version.split("+")[0] -@pytest.mark.parametrize( - "provider_prefix,repo,ref,path,path_type,status_code", - [ - ("gh", "binderhub-ci-repos/requirements", "master", "", "", 200), - ("gh", "binderhub-ci-repos%2Frequirements", "master", "", "", 400), - ("gh", "binderhub-ci-repos/requirements", "master/", "", "", 200), - ( - "gh", - "binderhub-ci-repos/requirements", - "20c4fe55a9b2c5011d228545e821b1c7b1723652", - "index.ipynb", - "file", - 200, - ), - ( - "gh", - "binderhub-ci-repos/requirements", - "20c4fe55a9b2c5011d228545e821b1c7b1723652", - "%2Fnotebooks%2Findex.ipynb", - "url", - 200, - ), - ("gh", "binderhub-ci-repos/requirements", "master", "has%20space", "file", 200), - ( - "gh", - "binderhub-ci-repos/requirements", - "master/", - "%2Fhas%20space%2F", - "file", - 200, - ), - ( - "gh", - "binderhub-ci-repos/requirements", - "master", - "%2Fhas%20space%2F%C3%BCnicode.ipynb", - "file", - 200, - ), - ], -) -async def test_loading_page( - app, provider_prefix, repo, ref, path, path_type, status_code -): - # repo = f'{org}/{repo_name}' - spec = f"{repo}/{ref}" - provider_spec = f"{provider_prefix}/{spec}" - query = f"{path_type}path={path}" if path else "" - uri = f"/v2/{provider_spec}?{query}" - r = await async_requests.get(app.url + uri) - assert r.status_code == status_code, f"{r.status_code} {uri}" - if status_code == 200: - soup = BeautifulSoup(r.text, "html5lib") - assert soup.find(id="log-container") - nbviewer_url = soup.find(id="nbviewer-preview").find("iframe").attrs["src"] - r = await async_requests.get(nbviewer_url) - assert r.status_code == 200, f"{r.status_code} {nbviewer_url}" - - @pytest.mark.parametrize( "origin,host,expected_origin", [ diff --git a/binderhub/tests/test_playwright.py b/binderhub/tests/test_playwright.py new file mode 100644 index 000000000..ad1c126ff --- /dev/null +++ b/binderhub/tests/test_playwright.py @@ -0,0 +1,188 @@ +""" +Integration tests using playwright +""" + +import subprocess +import sys +import time + +import pytest +import requests +import requests.exceptions +from playwright.sync_api import Page + +from binderhub import __version__ as binder_version + +from .utils import async_requests, random_port + + +@pytest.fixture(scope="module") +async def local_hub_local_binder(request): + """ + Set up a local docker based binder based on testing/local-binder-local-hub + + Requires docker to be installed and available + """ + port = random_port() + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "jupyterhub", + "--config", + "testing/local-binder-local-hub/jupyterhub_config.py", + f"--port={port}", + ] + ) + + url = f"http://127.0.0.1:{port}/services/binder/" + for i in range(10): + try: + resp = await async_requests.get(url) + if resp.status_code == 200: + break + except requests.exceptions.ConnectionError: + pass + time.sleep(1) + yield url + + proc.terminate() + proc.wait() + + +@pytest.mark.parametrize( + ("provider_prefix", "repo", "ref", "path", "path_type", "status_code"), + [ + ("gh", "binderhub-ci-repos/requirements", "master", "", "", 200), + ("gh", "binderhub-ci-repos%2Frequirements", "master", "", "", 400), + ("gh", "binderhub-ci-repos/requirements", "master/", "", "", 200), + ( + "gh", + "binderhub-ci-repos/requirements", + "20c4fe55a9b2c5011d228545e821b1c7b1723652", + "index.ipynb", + "file", + 200, + ), + ( + "gh", + "binderhub-ci-repos/requirements", + "20c4fe55a9b2c5011d228545e821b1c7b1723652", + "%2Fnotebooks%2Findex.ipynb", + "url", + 200, + ), + ("gh", "binderhub-ci-repos/requirements", "master", "has%20space", "file", 200), + ( + "gh", + "binderhub-ci-repos/requirements", + "master/", + "%2Fhas%20space%2F", + "file", + 200, + ), + ( + "gh", + "binderhub-ci-repos/requirements", + "master", + "%2Fhas%20space%2F%C3%BCnicode.ipynb", + "file", + 200, + ), + ], +) +async def test_loading_page( + local_hub_local_binder, + provider_prefix, + repo, + ref, + path, + path_type, + status_code, + page: Page, +): + spec = f"{repo}/{ref}" + provider_spec = f"{provider_prefix}/{spec}" + query = f"{path_type}path={path}" if path else "" + uri = f"/v2/{provider_spec}?{query}" + r = page.goto(local_hub_local_binder + uri) + + assert r.status == status_code + + if status_code == 200: + assert page.query_selector("#log-container") + nbviewer_url = page.query_selector("#nbviewer-preview iframe").get_attribute( + "src" + ) + r = await async_requests.get(nbviewer_url) + assert r.status_code == 200, f"{r.status_code} {nbviewer_url}" + + +@pytest.mark.parametrize( + ("repo", "ref", "path", "path_type", "shared_url"), + [ + ( + "binder-examples/requirements", + "", + "", + "", + "v2/gh/binder-examples/requirements/HEAD", + ), + ( + "binder-examples/requirements", + "master", + "", + "", + "v2/gh/binder-examples/requirements/master", + ), + ( + "binder-examples/requirements", + "master", + "some file with spaces.ipynb", + "file", + "v2/gh/binder-examples/requirements/master?labpath=some+file+with+spaces.ipynb", + ), + ( + "binder-examples/requirements", + "master", + "/some url with spaces?query=something", + "url", + "v2/gh/binder-examples/requirements/master?urlpath=%2Fsome+url+with+spaces%3Fquery%3Dsomething", + ), + ], +) +async def test_main_page( + local_hub_local_binder, page: Page, repo, ref, path, path_type, shared_url +): + resp = page.goto(local_hub_local_binder) + assert resp.status == 200 + + page.get_by_placeholder("GitHub repository name or URL").type(repo) + + if ref: + page.locator("#ref").type(ref) + + if path_type: + page.query_selector("#url-or-file-btn").click() + if path_type == "file": + page.locator("a:text-is('File')").click() + elif path_type == "url": + page.locator("a:text-is('URL')").click() + else: + raise ValueError(f"Unknown path_type {path_type}") + if path: + page.locator("#filepath").type(path) + + assert ( + page.query_selector("#basic-url-snippet").inner_text() + == f"{local_hub_local_binder}{shared_url}" + ) + + +async def test_about_page(local_hub_local_binder, page: Page): + r = page.goto(f"{local_hub_local_binder}about") + + assert r.status == 200 + + assert "This website is powered by" in page.content() + assert binder_version.split("+")[0] in page.content() diff --git a/binderhub/tests/utils.py b/binderhub/tests/utils.py index 247e629f5..17ce4ab31 100644 --- a/binderhub/tests/utils.py +++ b/binderhub/tests/utils.py @@ -2,6 +2,7 @@ import asyncio import io +import socket from concurrent.futures import ThreadPoolExecutor from urllib.parse import urlparse @@ -149,3 +150,12 @@ def _next(): # async_requests.get = requests.get returning a Future, etc. async_requests = _AsyncRequests() + + +def random_port() -> int: + """Get a single random port.""" + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + return port diff --git a/dev-requirements.txt b/dev-requirements.txt index 0e39e1c36..9a4afe828 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,6 +6,7 @@ jsonschema jupyter-repo2docker>=2021.08.0 jupyter_packaging>=0.10.4,<2 jupyterhub +nest-asyncio pytest pytest-asyncio pytest-cov diff --git a/pyproject.toml b/pyproject.toml index 144f60a94..4c14bc010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,6 @@ build-backend = "setuptools.build_meta" # black is used for autoformatting Python code -# -# ref: https://black.readthedocs.io/en/stable/ -# [tool.black] # target-version should be all supported versions, see # https://github.com/psf/black/issues/751#issuecomment-473066811 @@ -18,27 +15,18 @@ target-version = ["py38", "py39", "py310", "py311"] # isort is used for autoformatting Python code -# -# ref: https://pycqa.github.io/isort/ -# [tool.isort] profile = "black" # pytest is used for running Python based tests -# -# ref: https://docs.pytest.org/en/stable/ -# [tool.pytest.ini_options] -addopts = "--verbose --color=yes --durations=10" +addopts = "--verbose --color=yes --durations=10 --browser firefox" asyncio_mode = "auto" testpaths = ["tests"] timeout = "60" # pytest-cov / coverage is used to measure code coverage of tests -# -# ref: https://coverage.readthedocs.io/en/stable/config.html -# [tool.coverage.run] omit = [ "binderhub/tests/*",