diff --git a/.github/workflows/di.yml b/.github/workflows/di.yml index 52bea9801..04dbb0f80 100644 --- a/.github/workflows/di.yml +++ b/.github/workflows/di.yml @@ -7,35 +7,53 @@ on: workflow_dispatch: schedule: - cron: 41 3 * * * + pull_request: jobs: test-app: - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: matrix: - tag: [latest, stable] - browser: [chrome, firefox] + tag: [latest] + browser: [Chrome, Firefox] + python-version: ['3.8', '3.10'] + firefox: ['84.0'] fail-fast: false - steps: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: - name: Check out app uses: actions/checkout@v2 - - name: Test app - uses: aiidalab/aiidalab-test-app-action@v2 + - name: Cache Python dependencies + uses: actions/cache@v1 with: - image: aiidalab/aiidalab-docker-stack:${{ matrix.tag }} - browser: ${{ matrix.browser }} - name: quantum-espresso + 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 dependencies for test + run: | + pip install -U -r requirements_test.txt + + - name: Set jupyter token env + run: echo "JUPYTER_TOKEN=$(openssl rand -hex 32)" >> $GITHUB_ENV - - name: Upload screenshots as artifacts - uses: actions/upload-artifact@v2 - if: ${{ always() }} + - uses: browser-actions/setup-firefox@latest with: - name: Screenshots-${{ matrix.tag }}-${{ matrix.browser }} - path: screenshots/ + firefox-version: ${{ matrix.firefox }} + run: which firefox + + - name: Run pytest + run: | + pytest --driver ${{ matrix.browser }} + env: + TAG: ${{ matrix.tag }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ea1f7811..ca0e05cc5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,3 +44,8 @@ repos: rev: 0.19.2 hooks: - id: check-github-workflows + + - repo: https://github.com/kynan/nbstripout + rev: 0.5.0 + hooks: + - id: nbstripout diff --git a/aiidalab_qe/process.py b/aiidalab_qe/process.py index 1280c768d..c2d0a5e71 100644 --- a/aiidalab_qe/process.py +++ b/aiidalab_qe/process.py @@ -4,8 +4,8 @@ import ipywidgets as ipw import traitlets -from aiida.cmdline.utils.query.calculation import CalculationQueryBuilder from aiida.orm import load_node +from aiida.tools.query.calculation import CalculationQueryBuilder class WorkChainSelector(ipw.HBox): diff --git a/aiidalab_qe/report.py b/aiidalab_qe/report.py index f4883bd56..268ade793 100644 --- a/aiidalab_qe/report.py +++ b/aiidalab_qe/report.py @@ -1,3 +1,4 @@ +from aiida.orm import WorkChainNode from aiida.plugins import WorkflowFactory PwBaseWorkChain = WorkflowFactory("quantumespresso.pw.base") @@ -25,8 +26,8 @@ } -def _generate_report_dict(qeapp_wc): - builder_parameters = qeapp_wc.get_extra("builder_parameters", {}) +def _generate_report_dict(qeapp_wc: WorkChainNode): + builder_parameters = qeapp_wc.base.extras.get("builder_parameters", {}) # Properties run_relax = builder_parameters.get("relax_type") != "none" diff --git a/aiidalab_qe/setup_codes.py b/aiidalab_qe/setup_codes.py index 83f3a646f..56095df79 100644 --- a/aiidalab_qe/setup_codes.py +++ b/aiidalab_qe/setup_codes.py @@ -71,19 +71,20 @@ def _setup_code(code_name, computer_name="localhost"): [ "verdi", "code", - "setup", + "create", + "core.code.installed", "--non-interactive", "--label", f"{code_name}-{QE_VERSION}", "--description", f"{code_name}.x ({QE_VERSION}) setup by AiiDAlab.", - "--input-plugin", + "--default-calc-job-plugin", f"quantumespresso.{code_name}", "--computer", computer_name, "--prepend-text", - f"conda activate {CONDA_ENV_PREFIX}\nexport OMP_NUM_THREADS=1", - "--remote-abs-path", + f'eval "$(conda shell.posix hook)"\nconda activate {CONDA_ENV_PREFIX}\nexport OMP_NUM_THREADS=1', + "--filepath-executable", CONDA_ENV_PREFIX.joinpath("bin", f"{code_name}.x"), ], check=True, diff --git a/aiidalab_qe/steps.py b/aiidalab_qe/steps.py index 29aadb8dc..03704b3f1 100644 --- a/aiidalab_qe/steps.py +++ b/aiidalab_qe/steps.py @@ -11,7 +11,7 @@ import traitlets from aiida.common import NotExistent from aiida.engine import ProcessBuilderNamespace, ProcessState, submit -from aiida.orm import ProcessNode, WorkChainNode, load_code +from aiida.orm import WorkChainNode, load_code, load_node from aiida.plugins import DataFactory from aiida_quantumespresso.common.types import ElectronicType, RelaxType, SpinType from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain @@ -34,10 +34,10 @@ ) from aiidalab_qe_workchain import QeAppWorkChain -StructureData = DataFactory("structure") -Float = DataFactory("float") -Dict = DataFactory("dict") -Str = DataFactory("str") +StructureData = DataFactory("core.structure") +Float = DataFactory("core.float") +Dict = DataFactory("core.dict") +Str = DataFactory("core.str") class WorkChainSettings(ipw.VBox): @@ -601,9 +601,9 @@ def _identify_submission_blockers(self): and len( set( ( - self.pw_code.value.computer.pk, - self.dos_code.value.computer.pk, - self.projwfc_code.value.computer.pk, + load_code(self.pw_code.value).computer.pk, + load_code(self.dos_code.value).computer.pk, + load_code(self.projwfc_code.value).computer.pk, ) ) ) @@ -654,7 +654,7 @@ def _auto_select_code(self, change): try: code_widget = getattr(self, code) code_widget.refresh() - code_widget.value = load_code(DEFAULT_PARAMETERS[code]) + code_widget.value = load_code(DEFAULT_PARAMETERS[code]).uuid except NotExistent: pass @@ -676,9 +676,10 @@ def _show_alert_message(self, message, alert_class="info"): def _update_resources(self, change): if change["new"] and ( change["old"] is None - or change["new"].computer.pk != change["old"].computer.pk + or load_code(change["new"]).computer.pk + != load_code(change["old"]).computer.pk ): - self.set_resource_defaults(change["new"].computer) + self.set_resource_defaults(load_code(change["new"]).computer) def set_resource_defaults(self, computer=None): @@ -715,7 +716,7 @@ def _check_resources(self): return # No code selected, nothing to do. num_cpus = self.resources_config.num_cpus.value - on_localhost = self.pw_code.value.computer.get_hostname() == "localhost" + on_localhost = load_node(self.pw_code.value).computer.hostname == "localhost" if self.pw_code.value and on_localhost and num_cpus > 1: self._show_alert_message( "The selected code would be executed on the local host, but " @@ -754,7 +755,9 @@ def _observe_process(self, change): process_node = change["new"] if process_node is not None: self.input_structure = process_node.inputs.structure - builder_parameters = process_node.get_extra("builder_parameters", None) + builder_parameters = process_node.base.extras.get( + "builder_parameters", None + ) if builder_parameters is not None: self.set_selected_codes(builder_parameters) self._update_state() @@ -775,9 +778,9 @@ def get_input_parameters(self): run_pdos=self.workchain_settings.pdos_run.value, protocol=self.workchain_settings.workchain_protocol.value, # Codes - pw_code=self.pw_code.value.uuid, - dos_code=self.dos_code.value.uuid, - projwfc_code=self.projwfc_code.value.uuid, + pw_code=self.pw_code.value, + dos_code=self.dos_code.value, + projwfc_code=self.projwfc_code.value, # Advanced settings pseudo_family=self.pseudo_family_selector.value, ) @@ -795,18 +798,18 @@ def set_selected_codes(self, parameters): """Set the inputs in the GUI based on a set of parameters.""" # Codes - def _load_code(code): + def _get_code_uuid(code): if code is not None: try: - return load_code(code) + return load_code(code).uuid except NotExistent: return None with self.hold_trait_notifications(): # Codes - self.pw_code.value = _load_code(parameters["pw_code"]) - self.dos_code.value = _load_code(parameters["dos_code"]) - self.projwfc_code.value = _load_code(parameters["projwfc_code"]) + self.pw_code.value = _get_code_uuid(parameters["pw_code"]) + self.dos_code.value = _get_code_uuid(parameters["dos_code"]) + self.projwfc_code.value = _get_code_uuid(parameters["projwfc_code"]) def set_pdos_status(self): if self.workchain_settings.pdos_run.value: @@ -887,7 +890,7 @@ def update_builder(buildy, resources, npools): with self.hold_trait_notifications(): self.process = submit(builder) # Set the builder parameters on the work chain - self.process.set_extra("builder_parameters", parameters) + self.process.base.extras.set("builder_parameters", parameters) def reset(self): with self.hold_trait_notifications(): @@ -897,11 +900,14 @@ def reset(self): class ViewQeAppWorkChainStatusAndResultsStep(ipw.VBox, WizardAppWidgetStep): - process = traitlets.Instance(ProcessNode, allow_none=True) + process = traitlets.Unicode(allow_none=True) def __init__(self, **kwargs): self.process_tree = ProcessNodesTreeWidget() - ipw.dlink((self, "process"), (self.process_tree, "process")) + ipw.dlink( + (self, "process"), + (self.process_tree, "value"), + ) self.node_view = NodeViewWidget(layout={"width": "auto", "height": "auto"}) ipw.dlink( @@ -919,7 +925,7 @@ def __init__(self, **kwargs): self._update_state, ], ) - ipw.dlink((self, "process"), (self.process_monitor, "process")) + ipw.dlink((self, "process"), (self.process_monitor, "value")) super().__init__([self.process_status], **kwargs) @@ -934,7 +940,8 @@ def _update_state(self): if self.process is None: self.state = self.State.INIT else: - process_state = self.process.process_state + process = load_node(self.process) + process_state = process.process_state if process_state in ( ProcessState.CREATED, ProcessState.RUNNING, @@ -943,10 +950,10 @@ def _update_state(self): self.state = self.State.ACTIVE elif ( process_state in (ProcessState.EXCEPTED, ProcessState.KILLED) - or self.process.is_failed + or process.is_failed ): self.state = self.State.FAIL - elif self.process.is_finished_ok: + elif process.is_finished_ok: self.state = self.State.SUCCESS @traitlets.observe("process") diff --git a/aiidalab_qe/widgets.py b/aiidalab_qe/widgets.py index 9bef38a8a..58998ccb1 100644 --- a/aiidalab_qe/widgets.py +++ b/aiidalab_qe/widgets.py @@ -14,7 +14,7 @@ import ipywidgets as ipw import traitlets -from aiida.orm import CalcJobNode, Node +from aiida.orm import CalcJobNode, Node, load_node from aiidalab_widgets_base import register_viewer_widget, viewer from IPython.display import HTML, Javascript, clear_output, display @@ -242,7 +242,7 @@ def _observe_value(self, change): class CalcJobOutputFollower(traitlets.HasTraits): - calcjob = traitlets.Instance(CalcJobNode, allow_none=True) + calcjob_uuid = traitlets.Unicode(allow_none=True) filename = traitlets.Unicode(allow_none=True) output = traitlets.List(trait=traitlets.Unicode) lineno = traitlets.Int() @@ -258,14 +258,11 @@ def __init__(self, **kwargs): super().__init__(**kwargs) - @traitlets.observe("calcjob") + @traitlets.observe("calcjob_uuid") def _observe_calcjob(self, change): - try: - if change["old"].pk == change["new"].pk: - # Old and new process are identical. - return - except AttributeError: - pass + calcjob_uuid = change["new"] + if change["old"] == calcjob_uuid: + return with self._lock: # Stop following @@ -283,22 +280,22 @@ def _observe_calcjob(self, change): # (Re/)start following if change["new"]: self._follow_output_thread = Thread( - target=self._follow_output, args=(change["new"],) + target=self._follow_output, args=(calcjob_uuid,) ) self._follow_output_thread.start() - def _follow_output(self, calcjob): + def _follow_output(self, calcjob_uuid): """Monitor calcjob and orchestrate pushing and pulling of output.""" - self._pull_thread = Thread(target=self._pull_output, args=(calcjob,)) + self._pull_thread = Thread(target=self._pull_output) self._pull_thread.start() - self._push_thread = Thread(target=self._push_output, args=(calcjob,)) + self._push_thread = Thread(target=self._push_output, args=(calcjob_uuid,)) self._push_thread.start() def _fetch_output(self, calcjob): assert isinstance(calcjob, CalcJobNode) if "retrieved" in calcjob.outputs: try: - self.filename = calcjob.attributes["output_filename"] + self.filename = calcjob.base.attributes.get("output_filename") with calcjob.outputs.retrieved.open(self.filename) as f: return f.read().splitlines() except OSError: @@ -306,7 +303,7 @@ def _fetch_output(self, calcjob): elif "remote_folder" in calcjob.outputs: try: - fn_out = calcjob.attributes["output_filename"] + fn_out = calcjob.base.attributes.get("output_filename") self.filename = fn_out with NamedTemporaryFile() as tmpfile: calcjob.outputs.remote_folder.getfile(fn_out, tmpfile.name) @@ -318,9 +315,10 @@ def _fetch_output(self, calcjob): _EOF = None - def _push_output(self, calcjob, delay=0.2): + def _push_output(self, calcjob_uuid, delay=0.2): """Push new log lines onto the queue.""" lineno = 0 + calcjob = load_node(calcjob_uuid) while True: try: lines = self._fetch_output(calcjob) @@ -335,7 +333,7 @@ def _push_output(self, calcjob, delay=0.2): self._output_queue.put(self._EOF) break # noqa: B012 - def _pull_output(self, calcjob): + def _pull_output(self): """Pull new log lines from the queue and update traitlets.""" while True: item = self._output_queue.get() diff --git a/qe.ipynb b/qe.ipynb index bbd716e15..6f0f8b6db 100644 --- a/qe.ipynb +++ b/qe.ipynb @@ -30,9 +30,17 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "scrolled": false - }, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "os.environ[\"AIIDA_WARN_v3\"] = \"1\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, "outputs": [], "source": [ "import ipywidgets as ipw\n", @@ -88,7 +96,7 @@ "ipw.dlink((configure_qe_app_work_chain_step, 'smearing_settings'), (submit_qe_app_work_chain_step, 'smearing_settings'))\n", "ipw.dlink((configure_qe_app_work_chain_step, 'pseudo_family_selector'), (submit_qe_app_work_chain_step, 'pseudo_family_selector'))\n", "\n", - "ipw.dlink((submit_qe_app_work_chain_step, 'process'), (view_qe_app_work_chain_status_and_results_step, 'process'))\n", + "ipw.dlink((submit_qe_app_work_chain_step, 'process'), (view_qe_app_work_chain_status_and_results_step, 'process'), transform=lambda node: node.uuid if node is not None else None)\n", "\n", "# Add the application steps to the application\n", "app = WizardAppWidget(\n", @@ -157,7 +165,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -171,7 +179,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.9" + "version": "3.9.4" + }, + "vscode": { + "interpreter": { + "hash": "d4d1e4263499bec80672ea0156c357c1ee493ec2b1c70f0acce89fc37c4a6abe" + } } }, "nbformat": 4, diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 000000000..f94e1c0a5 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,4 @@ +pytest~=6.0 +pytest-docker~=1.0 +pytest-selenium~=4.0 +webdriver-manager~=3.8 diff --git a/setup.cfg b/setup.cfg index e7c4d6f21..659d2ef22 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,20 +22,22 @@ project_urls = [options] packages = find: install_requires = - Jinja2~=2.11.3 - aiida-core~=1.0 - aiida-quantumespresso~=3.5 - aiidalab-qe-workchain@https://github.com/aiidalab/aiidalab-qe/releases/download/v22.11.1/aiidalab_qe_workchain-22.11.1-py3-none-any.whl - aiidalab-widgets-base~=1.4.1 - filelock~=3.3.0 + Jinja2~=3.0 + aiida-core~=2.1 + aiida-quantumespresso~=4.1 + aiidalab-widgets-base==2.0.0a1 + filelock~=3.8 importlib-resources~=5.2.2 + numpy~=1.23 # pined for pymatgen widget-bandsplot~=0.2.8 -python_requires = >=3.7 +python_requires = >=3.8 [options.extras_require] dev = bumpver==2022.1119 pre-commit==2.11.1 +test = + requirements-test.txt [options.package_data] aiidalab_qe.parameters = qeapp.yaml diff --git a/src/aiidalab_qe_workchain/__init__.py b/src/aiidalab_qe_workchain/__init__.py index 20485fff3..1c666bac6 100644 --- a/src/aiidalab_qe_workchain/__init__.py +++ b/src/aiidalab_qe_workchain/__init__.py @@ -13,14 +13,14 @@ PwBandsWorkChain = WorkflowFactory("quantumespresso.pw.bands") PdosWorkChain = WorkflowFactory("quantumespresso.pdos") -Bool = DataFactory("bool") -Float = DataFactory("float") -Dict = DataFactory("dict") -Str = DataFactory("str") -XyData = DataFactory("array.xy") -StructureData = DataFactory("structure") -BandsData = DataFactory("array.bands") -Orbital = DataFactory("orbital") +Bool = DataFactory("core.bool") +Float = DataFactory("core.float") +Dict = DataFactory("core.dict") +Str = DataFactory("core.str") +XyData = DataFactory("core.array.xy") +StructureData = DataFactory("core.structure") +BandsData = DataFactory("core.array.bands") +Orbital = DataFactory("core.orbital") class QeAppWorkChain(WorkChain): diff --git a/src/setup.cfg b/src/setup.cfg index e386351b1..8ed7b7b6d 100644 --- a/src/setup.cfg +++ b/src/setup.cfg @@ -18,9 +18,9 @@ project_urls = [options] packages = find: install_requires = - aiida-core~=1.0 - aiida-quantumespresso~=3.2 -python_requires = >=3.7 + aiida-core~=2.1 + aiida-quantumespresso~=4.1 +python_requires = >=3.8 [flake8] ignore = diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..de7ff6347 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +import os +from urllib.parse import urljoin + +import pytest +import requests +from requests.exceptions import ConnectionError +from selenium.webdriver.common.by import By + + +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 notebook_service(docker_ip, docker_services): + """Ensure that HTTP service is up and responsive.""" + + docker_compose = docker_services._docker_compose + + # assurance for host user UID other that 1000 + chown_command = "exec -T -u root aiidalab bash -c 'chown -R jovyan:users /home/jovyan/apps/aiidalab-qe'" + docker_compose.execute(chown_command) + + install_command = "bash -c 'pip install -U .'" + command = f"exec --workdir /home/jovyan/apps/aiidalab-qe/src -T aiidalab {install_command}" + docker_compose.execute(command) + + install_command = "bash -c 'python tests/helper_dep_requirements.py && pip install -U -r /tmp/requirements.txt'" + command = ( + f"exec --workdir /home/jovyan/apps/aiidalab-qe -T aiidalab {install_command}" + ) + docker_compose.execute(command) + + # `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, wait_time=5.0): + url, token = notebook_service + url_with_token = urljoin(url, f"apps/apps/aiidalab-qe/{nb_path}?token={token}") + selenium.get(f"{url_with_token}") + selenium.implicitly_wait(wait_time) # must wait until the app loaded + + selenium.find_element(By.ID, "ipython-main-app") + selenium.find_element(By.ID, "notebook-container") + + return selenium + + return _selenium_driver + + +@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/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 000000000..4f0442961 --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,18 @@ +--- +version: '3.4' + +services: + + aiidalab: + image: 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/aiidalab-qe + ports: + - 8998:8888 diff --git a/tests/helper_dep_requirements.py b/tests/helper_dep_requirements.py new file mode 100644 index 000000000..8b0647034 --- /dev/null +++ b/tests/helper_dep_requirements.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +"""This helper script is for temporarily remove the +`aiidalab-qe-workchain` package from dependencies list so that +in the test it will not use the remote released source. +""" + +from configparser import ConfigParser + +cf = ConfigParser() +cf.read("setup.cfg") +lst = cf["options"]["install_requires"].split("\n") +requirements_lst = [i for i in lst if not i.startswith("aiidalab-qe-workchain")] + +with open("/tmp/requirements.txt", "w") as fh: + fh.write("\n".join(requirements_lst[1:])) diff --git a/tests/test_qe_app.py b/tests/test_qe_app.py index 0fca90044..7af7f747d 100755 --- a/tests/test_qe_app.py +++ b/tests/test_qe_app.py @@ -1,26 +1,28 @@ -#!/usr/bin/env python -import time - +import requests from selenium.webdriver.common.by import By -def test_qe_app_take_screenshot(selenium, url): - selenium.get(url("http://localhost:8100/apps/apps/quantum-espresso/qe.ipynb")) - selenium.set_window_size(1920, 985) - time.sleep(10) - selenium.get_screenshot_as_file("screenshots/qe-app.png") +def test_notebook_service_available(notebook_service): + url, token = notebook_service + response = requests.get(f"{url}/?token={token}") + assert response.status_code == 200 + + +def test_qe_app_take_screenshot(selenium_driver): + driver = selenium_driver("qe.ipynb", wait_time=30.0) + driver.set_window_size(1920, 985) + driver.get_screenshot_as_file("screenshots/qe-app.png") -def test_qe_app_select_silicon(selenium, url): - selenium.get(url("http://localhost:8100/apps/apps/quantum-espresso/qe.ipynb")) - selenium.set_window_size(1920, 985) - time.sleep(10) - selenium.find_element( - By.XPATH, '//li[@id="tab-key-17"]' +def test_qe_app_select_silicon(selenium_driver): + driver = selenium_driver("qe.ipynb", wait_time=30.0) + driver.set_window_size(1920, 985) + driver.find_element( + By.XPATH, "//*[text()='From Examples']" ).click() # click `From Examples` tab for input structure - selenium.find_element(By.XPATH, "//option[@value='Diamond']").click() - selenium.get_screenshot_as_file("screenshots/qe-app-select-diamond-selected.png") - confirm_button = selenium.find_element(By.XPATH, "//button[contains(.,'Confirm')]") + driver.find_element(By.XPATH, "//option[@value='Diamond']").click() + driver.get_screenshot_as_file("screenshots/qe-app-select-diamond-selected.png") + confirm_button = driver.find_element(By.XPATH, "//button[text()='Confirm']") confirm_button.location_once_scrolled_into_view # scroll into view confirm_button.click() - selenium.get_screenshot_as_file("screenshots/qe-app-select-diamond-confirmed.png") + driver.get_screenshot_as_file("screenshots/qe-app-select-diamond-confirmed.png")