diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 7f2151d3a..46e36aebb 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -38,11 +38,13 @@ jobs: - name: install pip dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install --upgrade build packaging setuptools wheel pytest + python3 -m pip install --upgrade build packaging setuptools wheel python3 -m pip install --upgrade -r requirements_mpi.txt python3 -m pip install --upgrade -r src/python/impactx/dashboard/requirements.txt + python3 -m pip install --upgrade -r src/python/impactx/dashboard/requirements.txt python3 -m pip install --upgrade -r examples/requirements.txt python3 -m pip install --upgrade -r tests/python/requirements.txt + set -e python3 -m pip install --upgrade pipx python3 -m pipx install openPMD-validator - name: CCache Cache diff --git a/.github/workflows/stubs.yml b/.github/workflows/stubs.yml index 7b5415e95..6696cf7e3 100644 --- a/.github/workflows/stubs.yml +++ b/.github/workflows/stubs.yml @@ -38,7 +38,24 @@ jobs: - name: Dependencies run: | .github/workflows/dependencies/gcc-openmpi.sh - python3 -m pip install -U pybind11-stubgen pre-commit + python3 -m pip install -U pybind11-stubgen + python3 -m pip install seleniumbase pytest + + # Added: Install Firefox and geckodriver + - name: Install Browser Dependencies + run: | + sudo apt-get update + sudo apt-get install -y firefox-esr xvfb + wget https://github.com/mozilla/geckodriver/releases/download/v0.33.0/geckodriver-v0.33.0-linux64.tar.gz + tar -xzf geckodriver-v0.33.0-linux64.tar.gz + sudo mv geckodriver /usr/local/bin/ + sudo chmod +x /usr/local/bin/geckodriver + + # Added: Start virtual display + - name: Start Virtual Display + run: | + Xvfb :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + echo "DISPLAY=:99" >> $GITHUB_ENV - name: Set Up Cache uses: actions/cache@v4 @@ -81,7 +98,8 @@ jobs: - name: Unit tests run: | - mpiexec -np 1 python3 -m pytest tests/python/ + export DISPLAY=:99 + mpiexec -np 1 python3 -m pytest -v tests/python/ - uses: stefanzweifel/git-auto-commit-action@v5 name: Commit Updated Stub Files diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 3e10db72c..9b27acfc8 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -71,9 +71,11 @@ jobs: python3 -m pip install -U pip python3 -m pip install -U build packaging setuptools wheel + python3 -m pip install -U build packaging setuptools wheel python3 -m pip install -U -r requirements.txt python3 -m pip install -U -r src/python/impactx/dashboard/requirements.txt python3 -m pip install -U -r examples/requirements.txt + python3 -m pip install -U -r src/python/impactx/dashboard/requirements.txt python3 -m pip install -U -r tests/python/requirements.txt python3 -m pip install -U openPMD-validator - name: Build @@ -184,6 +186,7 @@ jobs: python3 -m pip install -U -r requirements.txt python3 -m pip install -U -r src/python/impactx/dashboard/requirements.txt python3 -m pip install -U -r examples/requirements.txt + python3 -m pip install -U -r src/python/impactx/dashboard/requirements.txt python3 -m pip install -U -r tests/python/requirements.txt python3 -m pip install -U openPMD-validator - name: Build diff --git a/src/python/impactx/dashboard/Analyze/plotsMain.py b/src/python/impactx/dashboard/Analyze/plotsMain.py index 5a8a02bb4..124fad809 100644 --- a/src/python/impactx/dashboard/Analyze/plotsMain.py +++ b/src/python/impactx/dashboard/Analyze/plotsMain.py @@ -175,6 +175,7 @@ def on_filtered_data_change(**kwargs): @ctrl.add("run_simulation") def run_simulation_and_store(): + state.simulation_complete = (True,) state.plot_options = available_plot_options(simulationClicked=True) run_simulation_impactX() update_plot() @@ -228,7 +229,7 @@ def plot(): with vuetify.VCard(style="height: 50vh; width: 150vh;"): with vuetify.VTabs(v_model=("active_tab", 0)): vuetify.VTab("Plot") - vuetify.VTab("Interact") + vuetify.VTab("Interact", id="interact") vuetify.VDivider() with vuetify.VTabsItems(v_model="active_tab"): with vuetify.VTabItem(): diff --git a/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py b/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py index a1f9d6c2b..ca0af5848 100644 --- a/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py +++ b/src/python/impactx/dashboard/Input/distributionParameters/distributionMain.py @@ -162,7 +162,6 @@ def on_distribution_type_change(**kwargs): @ctrl.add("updateDistributionParameters") def on_distribution_parameter_change(parameter_name, parameter_value, parameter_type): - parameter_value, input_type = generalFunctions.determine_input_type(parameter_value) error_message = generalFunctions.validate_against(parameter_value, parameter_type) update_distribution_parameters(parameter_name, parameter_value, error_message) @@ -198,6 +197,7 @@ def card(): with vuetify.VCol(cols=6): vuetify.VCombobox( label="Select Distribution", + id="selected_distribution", v_model=("selectedDistribution",), items=("listOfDistributions",), dense=True, @@ -205,6 +205,7 @@ def card(): with vuetify.VCol(cols=6): vuetify.VSelect( v_model=("selectedDistributionType",), + id="selected_distribution_type", label="Type", items=(["Twiss", "Quadratic Form"],), dense=True, @@ -221,6 +222,7 @@ def card(): ): vuetify.VTextField( label=("parameter.parameter_name",), + id=("parameter.parameter_name",), v_model=("parameter.parameter_default_value",), suffix=("parameter.parameter_units",), change=( diff --git a/src/python/impactx/dashboard/Input/inputParameters/inputMain.py b/src/python/impactx/dashboard/Input/inputParameters/inputMain.py index baa5a6386..f98bdc3a1 100644 --- a/src/python/impactx/dashboard/Input/inputParameters/inputMain.py +++ b/src/python/impactx/dashboard/Input/inputParameters/inputMain.py @@ -141,6 +141,7 @@ def card(self): vuetify.VTextField( v_model=("npart",), label="Number of Particles", + id="npart", error_messages=("npart_validation",), change=( ctrl.on_input_change, @@ -154,6 +155,7 @@ def card(self): vuetify.VTextField( v_model=("kin_energy",), label="Kinetic Energy", + id="kin_energy", error_messages=("kin_energy_validation",), change=( ctrl.on_input_change, @@ -167,6 +169,7 @@ def card(self): vuetify.VSelect( v_model=("kin_energy_unit",), label="Unit", + id="kin_energy_unit", items=(["meV", "eV", "keV", "MeV", "GeV", "TeV"],), change=(ctrl.kin_energy_unit_change, "[$event]"), dense=True, @@ -175,6 +178,7 @@ def card(self): with vuetify.VCol(cols=8, classes="py-0"): vuetify.VTextField( label="Bunch Charge", + id="bunch_charge_C", v_model=("bunch_charge_C",), error_messages=("bunch_charge_C_validation",), change=( diff --git a/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py b/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py index 5371042c2..144610daf 100644 --- a/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py +++ b/src/python/impactx/dashboard/Input/latticeConfiguration/latticeMain.py @@ -165,7 +165,6 @@ def on_add_lattice_element_click(): def on_lattice_element_parameter_change( index, parameter_name, parameter_value, parameter_type ): - parameter_value, input_type = generalFunctions.determine_input_type(parameter_value) error_message = generalFunctions.validate_against(parameter_value, parameter_type) update_latticeElement_parameters( @@ -253,6 +252,7 @@ def card(): with vuetify.VCol(cols=8): vuetify.VCombobox( label="Select Accelerator Lattice", + id="selected_lattice", v_model=("selectedLattice", None), items=("listOfLatticeElements",), error_messages=("isSelectedLatticeListEmpty",), @@ -262,6 +262,7 @@ def card(): with vuetify.VCol(cols="auto"): vuetify.VBtn( "ADD", + id="add_button", color="primary", dense=True, classes="mr-2", @@ -270,6 +271,7 @@ def card(): with vuetify.VCol(cols="auto"): vuetify.VBtn( "CLEAR", + id="clear_button", color="secondary", dense=True, classes="mr-2", @@ -279,6 +281,7 @@ def card(): vuetify.VIcon( "mdi-cog", click="showDialog_settings = true", + id="lattice_settings_icon", ) with vuetify.VRow(): with vuetify.VCol(): @@ -337,6 +340,7 @@ def card(): ): vuetify.VTextField( label=("parameter.parameter_name",), + id=("parameter.parameter_name + index",), v_model=( "parameter.parameter_default_value", ), @@ -420,6 +424,7 @@ def dialog_lattice_settings(): with vuetify.VCol(no_gutters=True): vuetify.VTextField( v_model=("nsliceDefaultValue",), + id="nslice_default_value", change=( ctrl.nsliceDefaultChange, "['nslice', $event]", @@ -431,3 +436,13 @@ def dialog_lattice_settings(): style="max-width: 75px", classes="ma-0 pa-0", ) + vuetify.VDivider() + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn( + "Close", + id="lattice_settings_close", + color="primary", + text=True, + click="showDialog_settings = false", + ) diff --git a/src/python/impactx/dashboard/Input/trameFunctions.py b/src/python/impactx/dashboard/Input/trameFunctions.py index 599e00c03..a423389cc 100644 --- a/src/python/impactx/dashboard/Input/trameFunctions.py +++ b/src/python/impactx/dashboard/Input/trameFunctions.py @@ -40,4 +40,4 @@ def create_route(route_title, mdi_icon): with vuetify.VListItemIcon(): vuetify.VIcon(mdi_icon) with vuetify.VListItemContent(): - vuetify.VListItemTitle(route_title) + vuetify.VListItemTitle(route_title, id=f"{route_title}_route") diff --git a/src/python/impactx/dashboard/Toolbar/toolbarMain.py b/src/python/impactx/dashboard/Toolbar/toolbarMain.py index 4b71b0aca..20454a56d 100644 --- a/src/python/impactx/dashboard/Toolbar/toolbarMain.py +++ b/src/python/impactx/dashboard/Toolbar/toolbarMain.py @@ -51,6 +51,7 @@ def plot_options(): v_model=("active_plot", "1D plots over s"), items=("plot_options",), label="Select plot to view", + id="select_plot", hide_details=True, dense=True, style="max-width: 250px", @@ -61,11 +62,23 @@ def plot_options(): def run_simulation_button(): vuetify.VBtn( "Run Simulation", + id="run_simulation_button", style="background-color: #00313C; color: white; margin: 0 20px;", click=ctrl.run_simulation, disabled=("disableRunSimulationButton", True), ) + @staticmethod + def show_simulation_complete(): + vuetify.VAlert( + "Simulation Complete", + v_model=("simulation_complete", False), + id="simulation_complete", + type="success", + dense=True, + classes="mt-4", + ) + @staticmethod def dashboard_info(): """ @@ -106,6 +119,7 @@ def run_toolbar(): (ToolbarElements.dashboard_info(),) (vuetify.VSpacer(),) + (ToolbarElements.show_simulation_complete(),) (ToolbarElements.run_simulation_button(),) @staticmethod diff --git a/src/python/impactx/dashboard/__main__.py b/src/python/impactx/dashboard/__main__.py index 4f15fa468..0598499fe 100644 --- a/src/python/impactx/dashboard/__main__.py +++ b/src/python/impactx/dashboard/__main__.py @@ -62,7 +62,7 @@ # GUI # ----------------------------------------------------------------------------- def init_terminal(): - with xterm.XTerm(v_if="$route.path == '/Run'") as term: + with xterm.XTerm(v_show="$route.path == '/Run'", id="xterm_component") as term: ctrl.terminal_print = term.writeln diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 9be50f672..fb54bc92c 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -10,6 +10,9 @@ file(MAKE_DIRECTORY ${pytest_rundir}) file(COPY ${ImpactX_SOURCE_DIR}/examples DESTINATION ${pytest_rundir}) +file(COPY ${ImpactX_SOURCE_DIR}/src/python/impactx/dashboard +DESTINATION ${pytest_rundir}) + # run add_test(NAME ${pytest_name} COMMAND ${Python_EXECUTABLE} -m pytest -s -vvvv @@ -22,3 +25,9 @@ set_property(TEST ${pytest_name} APPEND PROPERTY ENVIRONMENT "OMP_NUM_THREADS=2" # set PYTHONPATH and PATH (for .dll files) impactx_test_set_pythonpath(${pytest_name}) + +# Add environment variables needed for dashboard tests +set_property(TEST ${pytest_name} APPEND PROPERTY ENVIRONMENT + "DISPLAY=:99" + "PYTHONPATH=${pytest_rundir}:$ENV{PYTHONPATH}" +) diff --git a/tests/python/dashboard/test_dashboard.py b/tests/python/dashboard/test_dashboard.py new file mode 100644 index 000000000..b376f45c4 --- /dev/null +++ b/tests/python/dashboard/test_dashboard.py @@ -0,0 +1,92 @@ +import importlib + +import pytest +from util import ( + check_until_visible, + set_input_value, + start_dashboard, + wait_for_dashboard_ready, + wait_for_ready, +) + +TIMEOUT = 60 + + +@pytest.mark.skipif( + importlib.util.find_spec("seleniumbase") is None, + reason="seleniumbase is not available", +) +def test_dashboard(): + """ + This test runs the FODO example on the dashboard and verifies + that the simulation has run successfully. + """ + from seleniumbase import SB + + app_process = None + + try: + with SB(headless=True) as sb: + app_process = start_dashboard() + wait_for_dashboard_ready(app_process, timeout=TIMEOUT) + + url = "http://localhost:8080/index.html#/Input" + sb.open(url) + + wait_for_ready(sb, ".trame__loader", TIMEOUT) + + # Adjust beam properties + set_input_value(sb, "npart", 10000) + set_input_value(sb, "kin_energy", 2.0e3) + set_input_value(sb, "bunch_charge_C", 1.0e-9) + + # Change distribution type to "Quadratic" + sb.click("#selected_distribution_type") + sb.click("div.v-list-item__title:contains('Quadratic Form')") + + # Adjust beam distribution + set_input_value(sb, "selected_distribution", "Waterbag") + set_input_value(sb, "lambdaX", 3.9984884770e-5) + set_input_value(sb, "lambdaY", 3.9984884770e-5) + set_input_value(sb, "lambdaT", 1.0e-3) + set_input_value(sb, "lambdaPx", 2.6623538760e-5) + set_input_value(sb, "lambdaPy", 2.6623538760e-5) + set_input_value(sb, "lambdaPt", 2.0e-3) + set_input_value(sb, "muxpx", -0.846574929020762) + set_input_value(sb, "muypy", 0.846574929020762) + set_input_value(sb, "mutpt", 0.0) + + # Adjust lattice configuration + sb.click("#lattice_settings_icon") + set_input_value(sb, "nslice_default_value", 25) + sb.click("#lattice_settings_close") + sb.click("#clear_button") + set_input_value(sb, "selected_lattice", "Drift") + sb.click("#add_button") + set_input_value(sb, "ds0", 0.25) + set_input_value(sb, "selected_lattice", "Quad") + sb.click("#add_button") + set_input_value(sb, "ds1", 1.0) + set_input_value(sb, "k1", 1.0) + set_input_value(sb, "selected_lattice", "Drift") + sb.click("#add_button") + set_input_value(sb, "ds2", 0.5) + set_input_value(sb, "selected_lattice", "Quad") + sb.click("#add_button") + set_input_value(sb, "ds3", 1.0) + set_input_value(sb, "k3", -1.0) + set_input_value(sb, "selected_lattice", "Drift") + sb.click("#add_button") + set_input_value(sb, "ds4", 0.25) + + # Run simulation + sb.click("#Run_route") + sb.click("#run_simulation_button") + + assert check_until_visible( + sb, "#simulation_complete" + ), "Simulation did not complete successfully." + + finally: + if app_process is not None: + app_process.terminate() diff --git a/tests/python/dashboard/util.py b/tests/python/dashboard/util.py new file mode 100644 index 000000000..1d7d2d624 --- /dev/null +++ b/tests/python/dashboard/util.py @@ -0,0 +1,102 @@ +import os +import subprocess +import sys +import time + +from selenium.common.exceptions import ( + ElementNotInteractableException, + JavascriptException, +) + + +def wait_for_ready(sb, element_name, timeout=10): + """ + Waits until the specified element is present in the DOM. + """ + for i in range(timeout): + print(f"wait_for_ready {i}") + if sb.is_element_present(element_name): + sb.sleep(1) + else: + print("Ready") + return + + +def wait_for_dashboard_ready(process, timeout=60): + """ + Function waits until the dashboard server is ready by checking the process output. + """ + for i in range(timeout): + line = process.stdout.readline() + if line: + print(line, end="") + if "App running at:" in line: + print("Dashboard is ready!") + return + raise Exception("Dashboard did not start correctly.") + + +def set_input_value(sb, element_id, value, timeout=60): + """ + Function to clear, update, and trigger a change event on an input field by ID. + Waits until the element is interactable before performing actions. + """ + + selector = f"#{element_id}" + end_time = time.time() + timeout + + while True: + try: + sb.clear(selector) + sb.update_text(selector, value) + sb.send_keys(selector, "\n") + break + except (ElementNotInteractableException, JavascriptException): + if time.time() > end_time: + raise Exception( + f"Element {selector} not interactable after {timeout} seconds." + ) + + +def check_until_visible(sb, selector, timeout=10, interval=1): + """ + Function which retries checking if an element is visible. + """ + + end_time = time.time() + timeout + while time.time() < end_time: + if sb.is_element_visible(selector): + return True + time.sleep(interval) + return False + + +def find_repo_root(): + """ + Finds the root directory of the repository by looking for a .git directory. + """ + current_dir = os.path.abspath(os.path.dirname(__file__)) + while True: + if os.path.isdir(os.path.join(current_dir, ".git")): + return current_dir + parent_dir = os.path.dirname(current_dir) + if parent_dir == current_dir: + raise Exception("Repository root not found.") + current_dir = parent_dir + + +def start_dashboard(): + """ + Function which starts up impactx-dashboard server. + """ + repo_root = find_repo_root() + working_directory = os.path.join(repo_root, "src", "python", "impactx") + working_directory = os.path.normpath(working_directory) + + return subprocess.Popen( + [sys.executable, "-m", "dashboard", "--server"], + cwd=working_directory, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt index 1052c9366..318cac888 100644 --- a/tests/python/requirements.txt +++ b/tests/python/requirements.txt @@ -1,2 +1,3 @@ -r ../../examples/requirements.txt pytest +seleniumbase