diff --git a/.github/config/profile.yaml b/.github/config/profile.yaml new file mode 100644 index 0000000..af18201 --- /dev/null +++ b/.github/config/profile.yaml @@ -0,0 +1,12 @@ +--- +profile: test +email: aiida@localhost +first_name: Giuseppe +last_name: Verdi +institution: Khedivial +repository: /tmp/ +db_host: 127.0.0.1 +db_port: 5432 +db_name: postgres +db_username: postgres +db_password: postgres diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..bf70f51 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,106 @@ +name: ci + +on: [push, pull_request] + +jobs: + + pre-commit: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Cache Python dependencies + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: pip-pre-commit-${{ hashFiles('**/setup.json') }} + restore-keys: + pip-pre-commit- + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install Python dependencies + run: pip install -e .[pre-commit,tests] + + - name: Run pre-commit + run: pre-commit run --all-files || ( git status --short ; git diff ; exit 1 ) + + test: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.9', '3.11', '3.12'] + aiida-core-version: ['2.5', '2.6'] + + services: + postgres: + image: postgres:12 + env: + POSTGRES_HOST: 127.0.0.1 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + rabbitmq: + image: rabbitmq:latest + ports: + - 5672:5672 + + steps: + - uses: actions/checkout@v2 + + - name: Cache Python dependencies + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: pip-${{ matrix.python-version }}-tests-${{ hashFiles('**/setup.json') }} + restore-keys: + pip-${{ matrix.python-version }}-tests + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install aiida-core + run: pip install aiida-core==${{ matrix.aiida-core-version }} + + - name: Install Python dependencies + run: | + pip install -e .[pre-commit,tests] + playwright install + pip list + + - name: Install system dependencies + run: sudo apt update && sudo apt install --no-install-recommends graphviz + + - name: Create AiiDA profile + run: verdi setup -n --config .github/config/profile.yaml + + - name: Install Dependencies, Start React Application + working-directory: aiida_workgraph_web_ui/frontend + run: | + npm install + npm run build + + - name: Run pytest + env: + AIIDA_WARN_v3: 1 + run: | + # Have to split tests into see issue #225 + pytest -m backend -v --cov --durations=0 + pytest -m frontend -v --cov-append --durations=0 + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: python-${{ matrix.python-version }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..888d8c3 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,45 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build web frontend package + working-directory: aiida_workgraph_web_ui/frontend/ + run: | + npm install + npm run build + - name: Build package + run: + python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c45e665 --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +.ipynb_checkpoints + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/source/**/*.txt + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# +*.pdf +tests/work +/tests/**/*.png +/tests/**/*txt +/tests/**/*html +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..589c22e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/pycqa/flake8 + rev: '6.0.0' + hooks: + - id: flake8 + args: ['--max-line-length=121', '--ignore=F821, F722, E203, W503'] + exclude: ^docs/gallery/ + +- repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..301384f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,27 @@ +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "miniconda3-3.12-24.1" # note that libmamba-solver is available since 22.1 + nodejs: "20" # maybe need to be also miniconda + jobs: + post_create_environment: + - python -m pip install --no-cache-dir .[docs] + - python -m pip install --exists-action=w --no-cache-dir -r docs/requirements.txt + - rabbitmq-server -detached + - sleep 10 + - rabbitmq-diagnostics status + - verdi presto + - verdi daemon start + - verdi status + - aiida-pseudo install sssp -x PBEsol + - verdi group list + - cat /proc/cpuinfo | grep processor | wc -l + +conda: + environment: docs/environment.yml + +# Build from the docs/ directory with Sphinx +sphinx: + configuration: docs/source/conf.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bf33479 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,118 @@ + +### Running tests + +For running the tests you require only need start rabbitmq in the background. +The profile that is created during the tests will automatically check its configuration. +To run all tests use + +```console +pytest +``` + +To run only backend tests run + +```console +pytest -m backend +``` + +To run only frontend tests +```console +pytest -m frontend +``` + +To not run these tests you can use the markers in the following way + +```console +pytest -m "not backend and not frontend" +``` + +#### Setting path for python executable for pythonjob tests + +By default the pythonjob will use the executable `python3` to execute the calcjobs in the tests. +If you want to specify to use a different python path (e.g. from your environment manager). +To change the default python path you can set the environment variable +```console +PYTEST_PYTHONJOB_PYTHON_EXEC_PATH=/home/user/pyvenv/workgraph-dev/bin/python pytest tests/test_python.py +``` + +#### Running frontend tests in headed mode + +To debug the frontend tests you often want to see what happens in the tests. +By default they are run in headless mode, so no browser is shown. +To run the frontend tests in headed mode for you have to set an environment variable like this +```console +PYTEST_PLAYWRIGHT_HEADLESS=no pytest -m frontend +``` + +For the frontend tests we start a web server at port `8000`, please free this address for before running the frontend tests. + +### Development on the GUI + +For the development on the GUI we use the [REACT](https://react.dev) library +which can automatically refresh on changes of the JS files. To start the backend +server please run + +```console +python aiida_workgraph_web_ui/backend/main.py +``` + +then start the frontend server with +```console +npm --prefix aiida_workgraph_web_ui/frontend start +``` + +The frontend server will refresh + +#### Tools for writing frontend tests + +To determine the right commands for invoking DOM elements playwright offers a +tool that outputs commands while navigating through the GUI. It requires a +webserver to be running so it can be started with +```console +workgraph web start +playwright codegen +``` + +#### Troubleshooting + +##### Tests are not updating after changes in code + +You might want to clean your cache + +```console +npm --prefix aiida_workgraph_web_ui/frontend cache clean +``` + +and also clear your browsers cache or try to start new private window. + + +### Building the docs + +We use sphinx to build the docs. You need the requirements in the extra +`.[docs]` dependency and the `docs/requirements.txt`. We have a `docs/Makefile` +that runs sphinx-build to build the docs. + +```console +pip install .[docs] +pip install -r docs/requirements.txt +make -C docs html + docs/build/html/index.html +``` + +#### Creating a new sphinx source file with executable code + +We use sphinx-gallery to integrate executable code into the doc. For that we +need create a sphinx-gallery script (an extended python file that can be parsed by +sphinx-gallery to generate an `.rst` with more structure) instead of a `.rst` +file. One can create a sphinx-gallery script from a jupyter notebook using the +[script](https://gist.github.com/chsasank/7218ca16f8d022e02a9c0deb94a310fe). +To execute the script you might need to install pandoc +```console +pip install pypandoc pypandoc_binary +``` +We put the converted sphinx-gallery script file ` + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..5e55543 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,26 @@ + +Welcome to AiiDA Workgraph Web UI's documentation! +==================================================== +The AiiDA Workgraph Web UI is a web interface for viewing and managing the workgraphs of AiiDA workflows. It provides a user-friendly interface for viewing the workgraphs, the details of the workgraphs, the logs of the jobs, the timeline of the execution, and the text summary of the workgraphs. + + +.. note:: + + If you encounter any problems, please update the widget to the latest version. If the problem persists, please report it on the `GitHub issue `_ + + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + installation + web + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..ec149cf --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,26 @@ +============ +Installation +============ + + +The recommended method of installation is to use the Python package manager |pip|_: + +.. code-block:: console + + $ pip install aiida-workgraph-web-ui + +This will install the latest stable version that was released to PyPI. + +To install the package from source, first clone the repository and then install using |pip|_: + +.. code-block:: console + + $ git clone https://github.com/aiidateam/aiida-workgraph-web-ui + $ pip install -e aiida-workgraph-web-ui + +The ``-e`` flag will install the package in editable mode, meaning that changes to the source code will be automatically picked up. + + + +.. |pip| replace:: ``pip`` +.. _pip: https://pip.pypa.io/en/stable/ diff --git a/docs/source/web.rst b/docs/source/web.rst new file mode 100644 index 0000000..8dcc5a3 --- /dev/null +++ b/docs/source/web.rst @@ -0,0 +1,63 @@ +Use Web UI to view the WorkGraph +=============================== +The web UI helps you to view and manage the workgraphs. + +Start the web server +-------------------- +Open a terminal, and run: + +.. code-block:: bash + + workgraph web start + +Then visit the page http://127.0.0.1:8000/workgraph, you can view all the workgraphs here. + +Stop the web server +------------------- +Open a terminal, and run: + +.. code-block:: bash + + workgraph web stop + +WorkGraph table +--------------- +The table shows all the workgraphs in the history. You can view the details of a workgraph by clicking it. You can also delete a workgraph by clicking the delete button. + +.. image:: ../_static/images/web-job-management.png + + +WorkGraph detail +---------------- +The detail page shows the details of a workgraph. You can view the details of each job in the workgraph. You can also view the logs of each job by clicking the log button. + +.. image:: ../_static/images/web-detail.png + + +WorkGraph logs +-------------- +The logs page shows the logs of a job. You can view the logs of a job here. + +.. image:: ../_static/images/web-logs.png + +Timeline +-------- + +The timeline page shows the timeline of the execution of the workgraph. You can view the timeline of the workgraph here. + + +.. image:: ../_static/images/web-timeline.png + +Text Summary +------------ +The text summary page shows the text summary of the workgraph. You can view the text summary of the workgraph here. + +.. image:: ../_static/images/web-summary.png + + +DataNode detail +---------------- + +The DataNode detail page shows the details of a DataNode. For a structure, it will show the 3D structure. + +.. image:: ../_static/images/web-atoms-viewer.png diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7af23b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,82 @@ +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "aiida-workgraph-web-ui" +dynamic = ["version"] # read from aiida_workgraph/__init__.py +description = "Design flexible node-based workflow for AiiDA calculation." +authors = [{name = "Xing Wang", email = "xingwang1991@gmail.com"}] +readme = "README.md" +license = {file = "LICENSE"} +classifiers = [ + "Development Status :: 1 - Planning", + "Framework :: AiiDA", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering" +] +keywords = ["aiida", "workflows"] +requires-python = ">=3.9" +dependencies = [ + "numpy~=1.21", + "scipy", + "ase", + "aiida-core>=2.3", + "cloudpickle", + "fastapi", + "uvicorn", + "pydantic_settings", +] + +[project.urls] +Documentation = "https://aiida-workgraph-web-ui.readthedocs.io" +Source = "https://github.com/aiidateam/aiida-workgraph-web-ui" + +[project.optional-dependencies] +docs = [ + "sphinx_rtd_theme", + "sphinx~=7.2", + "sphinx-copybutton~=0.5.0", + "sphinx-design~=0.5.0", + "sphinx-notfound-page~=1.0", + "sphinxext-rediraffe~=0.2.4", + "sphinx-intl~=2.1.0", + "sphinx-gallery", + "myst-nb~=1.0.0", + "nbsphinx", +] +pre-commit = [ + "pre-commit~=2.2", + "pylint~=2.17.4", +] +tests = [ + 'pgtest~=1.3', + "pytest~=7.0", + "pytest-cov~=2.7,<2.11", + "playwright", + "httpx", +] + +[project.scripts] +workgraph_web_ui = "aiida_workgraph_web_ui.backend.cmd_web:web" + +[project.entry-points."workgraph.cmdline"] +"web" = "aiida_workgraph_web_ui.backend.cmd_web:web" + +[tool.flit.sdist] +exclude = [ + "docs/", + "tests/", + "aiida_workgraph_web_ui/frontend/node_modules/", +] + + +[tool.pylint.format] +max-line-length = 120 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e69de29 diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py new file mode 100644 index 0000000..760e13d --- /dev/null +++ b/tests/backend/conftest.py @@ -0,0 +1,22 @@ +import pytest +from fastapi.testclient import TestClient +import os + + +@pytest.fixture(scope="module", autouse=True) +def aiida_profile(aiida_config, aiida_profile_factory): + """Create and load a profile with RabbitMQ as broker for backend tests.""" + with aiida_profile_factory(aiida_config, broker_backend="core.rabbitmq") as profile: + yield profile + + +@pytest.fixture(scope="module") +def set_backend_server_settings(aiida_profile): + os.environ["AIIDA_WORKGRAPH_GUI_PROFILE"] = aiida_profile.name + + +@pytest.fixture(scope="module") +def client(set_backend_server_settings): + from aiida_workgraph_web_ui.backend.app.api import app + + return TestClient(app) diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py new file mode 100644 index 0000000..fd21d5f --- /dev/null +++ b/tests/backend/test_backend.py @@ -0,0 +1,18 @@ +import pytest + + +@pytest.mark.backend +def test_root_route(client): + """Sample test case for the root route""" + response = client.get("/api") + assert response.status_code == 200 + assert response.json() == {"message": "Welcome to AiiDA-WorkGraph."} + + +@pytest.mark.backend +def test_workgraph_route(client, wg_calcfunction): + """Sample test case for the root route""" + wg_calcfunction.run() + response = client.get("/api/workgraph-data") + assert response.status_code == 200 + assert len(response.json()) > 0 diff --git a/tests/frontend/conftest.py b/tests/frontend/conftest.py new file mode 100644 index 0000000..e19231a --- /dev/null +++ b/tests/frontend/conftest.py @@ -0,0 +1,177 @@ +import pytest +from typing import Generator + +from playwright.sync_api import sync_playwright +from playwright.sync_api import expect + +from aiida_workgraph import WorkGraph + +import uvicorn + +from multiprocessing import Process, Value + +import contextlib +import threading +import time +import os +import socket +import errno + + +################################ +# Utilities for frontend tests # +################################ + + +class UvicornTestServer(uvicorn.Server): + """ + Suggested way to start a server in a background by developers + https://github.com/encode/uvicorn/discussions/1103#discussioncomment-941726 + """ + + def install_signal_handlers(self): + pass + + @contextlib.contextmanager + def run_in_thread(self): + thread = threading.Thread(target=self.run) + thread.start() + try: + print("wait for started") + while not self.started: + time.sleep(1e-3) + yield + finally: + self.should_exit = True + thread.join() + + +def run_uvicorn_web_server( + web_server_started, stop_web_server, **uvicorn_configuration +): + config = uvicorn.Config(**uvicorn_configuration) + uvicorn_web_server = UvicornTestServer(config=config) + with uvicorn_web_server.run_in_thread(): + with web_server_started.get_lock(): + web_server_started.value = 1 + print("Wait for signal to stop web server.") + while not stop_web_server.value: + time.sleep(1e-3) + + +############################### +# Fixtuers for frontend tests # +############################### + + +@pytest.fixture(scope="module") +def aiida_profile(aiida_config, aiida_profile_factory): + """Create and load a profile with RabbitMQ as broker for frontend tests.""" + with aiida_profile_factory(aiida_config, broker_backend="core.rabbitmq") as profile: + yield profile + + +@pytest.fixture(scope="module") +def set_backend_server_settings(aiida_profile): + os.environ["AIIDA_WORKGRAPH_GUI_PROFILE"] = aiida_profile.name + + +@pytest.fixture(scope="module") +def ran_wg_calcfunction( + aiida_profile, +) -> Generator[WorkGraph, None, None]: + """A workgraph with calcfunction.""" + + wg = WorkGraph(name="test_debug_math") + sumdiff1 = wg.add_task("workgraph.test_sum_diff", "sumdiff1", x=2, y=3) + sumdiff2 = wg.add_task("workgraph.test_sum_diff", "sumdiff2", x=4) + wg.add_link(sumdiff1.outputs[0], sumdiff2.inputs[1]) + wg.run() + yield wg + + +@pytest.fixture(scope="module") +def uvicorn_configuration(): + return { + "app": "aiida_workgraph_web_ui.backend.app.api:app", + "host": "0.0.0.0", + "port": 8000, + "log_level": "info", + "workers": 2, + } + + +@pytest.fixture(scope="module") +def web_server(set_backend_server_settings, uvicorn_configuration): + from ctypes import c_int8 + + web_server_started = Value(c_int8, 0) + stop_web_server = Value(c_int8, 0) + + test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + port = uvicorn_configuration["port"] + try: + test_socket.bind(("localhost", port)) + except socket.error as err: + if err.errno == errno.EADDRINUSE: + raise RuntimeError( + f"Port {port} is already in use. Please unbind the port, " + "so we can start a web server for the tests." + ) + else: + raise err + + test_socket.close() + + web_server_proc = Process( + target=run_uvicorn_web_server, + args=(web_server_started, stop_web_server), + kwargs=uvicorn_configuration, + ) + + web_server_proc.start() + + print("Wait for server being started.") + while not web_server_started.value: + time.sleep(1e-3) + + print("Web server started.") + yield web_server_proc + + with stop_web_server.get_lock(): + stop_web_server.value = 1 + + web_server_proc.join() + web_server_proc.close() + + +# Define a fixture for the browser +@pytest.fixture(scope="module") +def browser(): + pytest_playwright_headless = os.environ.get("PYTEST_PLAYWRIGHT_HEADLESS", "yes") + if pytest_playwright_headless == "yes": + headless = True + elif pytest_playwright_headless == "no": + headless = False + else: + raise ValueError( + f"Found environment variable PYTEST_PLAYWRIGHT_HEADLESS={pytest_playwright_headless}, " + 'please use "yes" or "no"' + ) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=headless) + yield browser + browser.close() + + +# Define a fixture for the page +@pytest.fixture(scope="module") +def page(browser): + with browser.new_page() as page: + # 5 seconds + page.set_default_timeout(5_000) + page.set_default_navigation_timeout(5_000) + expect.set_options(timeout=5_000) + yield page + page.close() diff --git a/tests/frontend/test_frontend.py b/tests/frontend/test_frontend.py new file mode 100644 index 0000000..2697210 --- /dev/null +++ b/tests/frontend/test_frontend.py @@ -0,0 +1,243 @@ +import pytest +import re + +from playwright.sync_api import expect + + +@pytest.mark.frontend +def test_homepage(web_server, page): + page.goto("http://localhost:8000") + + assert page.title() == "AiiDA-WorkGraph App" + + # Check for the existence of a specific element on the page + # Attempt to locate the element + element = page.locator("a[href='/workgraph']") + + # Check if the element is found + if not element.is_visible(): + pytest.fail("Element 'a[href='/wortre']' not found on the page") + + +@pytest.mark.frontend +def test_workgraph(web_server, page, ran_wg_calcfunction): + page.goto("http://localhost:8000") + # Since the routing is done by react-router-dom we cannot access it with a call like this + # page.goto("http://localhost:8000/workgraph" but have to navigate to it + page.click('a[href="/workgraph"]') + + # Check for the existence of a specific element on the page + + # Verify the presence of the WorkGraphTable heading + assert page.locator("h2").inner_text() == "WorkGraph" + + # Verify the presence of the search input + assert page.locator(".search-input").is_visible() + + # Verify the presence of the table header columns + # Verify the presence of the table header columns + assert page.locator("th:has-text('PK')").is_visible() + assert page.locator("th:has-text('Created')").is_visible() + assert page.locator("th:has-text('Process Label')").is_visible() + assert page.locator("th:has-text('State')").is_visible() + assert page.locator("th:has-text('Actions')").is_visible() + + # Verify the presence of pagination controls + assert page.locator(".pagination").is_visible() + + # Verify the presence of at least one row in the table + + # Ensures that the first row has appeared + page.get_by_role("cell", name="WorkGraph").hover() + + header_and_rows = page.get_by_role("row").all() + # we wait for the cell to appear + assert len(header_and_rows) == 2 + + +@pytest.mark.frontend +def test_workgraph_item(web_server, page, ran_wg_calcfunction): + page.goto("http://localhost:8000/workgraph/") + page.get_by_role("link", name=str(ran_wg_calcfunction.pk), exact=True).click() + # page.goto("http://localhost:8000/workgraph/{}".format(ran_wg_calcfunction.pk)) + # page.get_by_text() + # page.get_by_role("button", name="Arrange").click() + # ran_wg_calcfunction.pk)) + + page.get_by_text("sumdiff3").is_visible() + + # Simulate user interaction (e.g., clicking a button) + # Replace the selector with the actual selector of the button you want to click + # You should identify the button that triggers an action in your component + page.get_by_role("button", name="Arrange").click() + + gui_node = page.get_by_text("sumdiff2") + + # Verify that clicking on the "Real-time state" changes the color to green + gui_node_color = gui_node.evaluate( + "element => window.getComputedStyle(element).backgroundColor" + ) + assert gui_node_color == "rgba(0, 0, 0, 0)" + page.locator(".realtime-switch").click() + + # this waits until a green background appears + page.wait_for_function( + "selector => !!document.querySelector(selector)", + arg="div.title[style='background: green;']", + ) + gui_node_color = gui_node.evaluate( + "element => window.getComputedStyle(element).backgroundColor" + ) + assert gui_node_color == "rgb(0, 128, 0)" + + # Check the node-detail-view switch + page.locator(".detail-switch").click() + # pause here for debugging + # page.pause() + # page.wait_for_selector('[data-testid="input-x"] input', timeout=5000) # Wait for up to 5 seconds + # input_x_control = page.get_by_test_id("input-x").first.locator("input") + # assert input_x_control.input_value() == "2" + + # Verify that clicking on the gui node will pop up a sidebar + gui_node.click() + node_details_sidebar = page.get_by_text("CloseNode") + node_details_sidebar.wait_for(state="visible") + assert node_details_sidebar.is_visible() + node_details_sidebar.get_by_role("button", name="Close").click() + node_details_sidebar.wait_for(state="hidden") + assert node_details_sidebar.is_hidden() + + # verify Summary works + page.get_by_role("button", name="Summary").click() + assert page.get_by_text("typeWorkGraph").is_visible() + + # Verify that Log works + page.get_by_role("button", name="Log").click() + log_line = ( + page.locator(".log-content") + .locator("div") + .filter(has_text=re.compile(r".*Task: sumdiff2 finished.*")) + ) + log_line.wait_for(state="visible") + assert log_line.is_visible() + + # Verify that Time works + page.get_by_role("button", name="Time").click() + row = page.locator(".rct-sidebar-row ").get_by_text("sumdiff2") + row.wait_for(state="visible") + assert row.is_visible() + + +@pytest.mark.frontend +def test_datanode_item(web_server, page, ran_wg_calcfunction): + page.goto("http://localhost:8000/datanode/") + data_node_pk = ran_wg_calcfunction.nodes["sumdiff1"].inputs["x"].value.pk + page.get_by_role("link", name=str(data_node_pk), exact=True).click() + + # check if three rows (header plus 2) are present + expect(page.locator(":nth-match(tr, 3)")).to_be_visible() + rows = page.get_by_role("row").all() + assert "value" in rows[1].text_content() + assert "node_type" in rows[2].text_content() + + +@pytest.mark.frontend +def test_settings(web_server, page, ran_wg_calcfunction): + page.goto("http://localhost:8000/settings/") + # Verify that only one row is visible + expect(page.locator(":nth-match(tr, 1)")).to_be_visible() + expect(page.locator(":nth-match(tr, 2)")).to_be_hidden() + + # Verify that after starting the daemon one additional row appeared + page.get_by_role("button", name="Start Daemon").click() + expect(page.locator(":nth-match(tr, 2)")).to_be_visible() + expect(page.locator(":nth-match(tr, 3)")).to_be_hidden() + + # Verify that after adding workers one additional row appeared + page.get_by_role("button", name="Increase Workers").click() + expect(page.locator(":nth-match(tr, 3)")).to_be_visible() + expect(page.locator(":nth-match(tr, 4)")).to_be_hidden() + + # Verify that after decreasing workers one row disappears + page.get_by_role("button", name="Decrease Workers").click() + expect(page.locator(":nth-match(tr, 2)")).to_be_visible() + expect(page.locator(":nth-match(tr, 3)")).to_be_hidden() + + # Verify that stopping the daemon only the header row exists + page.get_by_role("button", name="Stop Daemon").click() + expect(page.locator(":nth-match(tr, 1)")).to_be_visible() + expect(page.locator(":nth-match(tr, 2)")).to_be_hidden() + + +############################ +# Tests mutating the state # +############################ +# The tests below change the state of the aiida database and cannot necessary be executed in arbitrary order. + + +@pytest.mark.frontend +def test_workgraph_delete(web_server, page, ran_wg_calcfunction): + """Tests that the last workgraph node in the table can be deleted successfully.""" + page.goto("http://localhost:8000") + # Since the routing is done by react-router-dom we cannot access it with a call like this + # page.goto("http://localhost:8000/workgraph" but have to navigate to it + page.click('a[href="/workgraph"]') + + # Ensures that the last data row has appeared, the first row is header + last_row = page.locator(":nth-match(tr, 2)") + expect(last_row).to_be_visible() + # verify that this is the last row + expect(page.locator(":nth-match(tr, 3)")).to_be_hidden() + + delete_button = last_row.locator(".delete-button") + + # Verify that cancel or closing the prompt does not delete the node + delete_button.click() + expect(page.get_by_text("Confirm deletion")).to_be_visible() + page.get_by_label("Close").click() + expect(last_row).to_be_visible() + + delete_button.click() + expect(page.get_by_text("Confirm deletion")).to_be_visible() + page.get_by_role("button", name="Cancel").click() + expect(last_row).to_be_visible() + + # Verify that confirming the prompt does delete the node + delete_button.click() + expect(page.get_by_text("Confirm deletion")).to_be_visible() + page.get_by_role("button", name="Confirm").click() + expect(last_row).to_be_hidden() + + +@pytest.mark.frontend +def test_datanode_delete(web_server, page, ran_wg_calcfunction): + """Tests that the last data node in the table can be deleted successfully.""" + page.goto("http://localhost:8000") + # Since the routing is done by react-router-dom we cannot access it with a call like this + # page.goto("http://localhost:8000/workgraph" but have to navigate to it + page.click('a[href="/datanode"]') + + # Ensures that the last data row has appeared + last_row = page.locator(":nth-match(tr, 7)") + expect(last_row).to_be_visible() + # verify that this is the last row + expect(page.locator(":nth-match(tr, 8)")).to_be_hidden() + + delete_button = last_row.locator(".delete-button") + + # Verify that cancel or closing the prompt does not delete the node + delete_button.click() + expect(page.get_by_text("Confirm deletion")).to_be_visible() + page.get_by_label("Close").click() + expect(last_row).to_be_visible() + + delete_button.click() + expect(page.get_by_text("Confirm deletion")).to_be_visible() + page.get_by_role("button", name="Cancel").click() + expect(last_row).to_be_visible() + + # Verify that confirming the prompt does delete the node + delete_button.click() + expect(page.get_by_text("Confirm deletion")).to_be_visible() + page.get_by_role("button", name="Confirm").click() + expect(last_row).to_be_hidden()