From c7984cc0d558bbf3a57db646634555fceaf4da2c Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 18 Sep 2024 19:38:21 -0300 Subject: [PATCH 01/29] feat: add tests for remote client ipythonconsole integration and plugin --- spyder/plugins/remoteclient/tests/Dockerfile | 37 +++ spyder/plugins/remoteclient/tests/__init__.py | 7 + spyder/plugins/remoteclient/tests/conftest.py | 234 ++++++++++++++++++ .../remoteclient/tests/docker-compose.yml | 6 + .../remoteclient/tests/test_ipythonconsole.py | 101 ++++++++ .../plugins/remoteclient/tests/test_plugin.py | 163 ++++++++++++ 6 files changed, 548 insertions(+) create mode 100644 spyder/plugins/remoteclient/tests/Dockerfile create mode 100644 spyder/plugins/remoteclient/tests/__init__.py create mode 100644 spyder/plugins/remoteclient/tests/conftest.py create mode 100644 spyder/plugins/remoteclient/tests/docker-compose.yml create mode 100644 spyder/plugins/remoteclient/tests/test_ipythonconsole.py create mode 100644 spyder/plugins/remoteclient/tests/test_plugin.py diff --git a/spyder/plugins/remoteclient/tests/Dockerfile b/spyder/plugins/remoteclient/tests/Dockerfile new file mode 100644 index 00000000000..72ae9e0d6f9 --- /dev/null +++ b/spyder/plugins/remoteclient/tests/Dockerfile @@ -0,0 +1,37 @@ +FROM ubuntu:focal AS ubuntu-base +ENV DEBIAN_FRONTEND noninteractive +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Setup the default user. +RUN useradd -rm -d /home/ubuntu -s /bin/bash -g root -G sudo ubuntu +RUN echo 'ubuntu:ubuntu' | chpasswd + +# Install required tools. +RUN apt-get -qq update \ + && apt-get -qq --no-install-recommends install curl \ + && apt-get -qq --no-install-recommends install ca-certificates \ + && apt-get -qq --no-install-recommends install vim-tiny \ + && apt-get -qq --no-install-recommends install sudo \ + && apt-get -qq --no-install-recommends install openssh-server \ + && apt-get -qq clean \ + && rm -rf /var/lib/apt/lists/* + +# Configure SSHD. +# SSH login fix. Otherwise user is kicked off after login +RUN sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd +RUN mkdir /var/run/sshd +RUN bash -c 'install -m755 <(printf "#!/bin/sh\nexit 0") /usr/sbin/policy-rc.d' +RUN ex +'%s/^#\zeListenAddress/\1/g' -scwq /etc/ssh/sshd_config +RUN ex +'%s/^#\zeHostKey .*ssh_host_.*_key/\1/g' -scwq /etc/ssh/sshd_config +RUN RUNLEVEL=1 dpkg-reconfigure openssh-server +RUN ssh-keygen -A -v +RUN update-rc.d ssh defaults + +# Configure sudo. +RUN ex +"%s/^%sudo.*$/%sudo ALL=(ALL:ALL) NOPASSWD:ALL/g" -scwq! /etc/sudoers + +# Generate and configure user keys. +USER ubuntu +RUN ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 + +CMD ["/usr/bin/sudo", "/usr/sbin/sshd", "-D", "-o", "ListenAddress=172.16.128.2"] diff --git a/spyder/plugins/remoteclient/tests/__init__.py b/spyder/plugins/remoteclient/tests/__init__.py new file mode 100644 index 00000000000..f0240e6d410 --- /dev/null +++ b/spyder/plugins/remoteclient/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Spyder Remote Client Tests""" diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py new file mode 100644 index 00000000000..936c9c83f74 --- /dev/null +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -0,0 +1,234 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Fixtures for the Spyder Remote Client plugin tests.""" + +from concurrent.futures import Future +import contextlib +import gc +import os +from pathlib import Path +import socket +import sys +import typing +import uuid + +import pytest +from pytest_docker.plugin import Services +from qtpy.QtWidgets import QMainWindow + +from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY +from spyder.app.cli_options import get_options +from spyder.config.manager import CONF +from spyder.plugins.ipythonconsole.plugin import IPythonConsole +from spyder.plugins.remoteclient.api.client import SpyderRemoteClient +from spyder.plugins.remoteclient.plugin import RemoteClient +from spyder.plugins.remoteclient.widgets import AuthenticationMethod +from spyder.utils.qthelpers import qapplication + + +T = typing.TypeVar("T") + +# NOTE: These credentials are hardcoded in the Dockerfile. +USERNAME = "ubuntu" +PASSWORD = USERNAME + +HERE = Path(__file__).resolve().parent + + +class MainWindowMock(QMainWindow): + """QMainWindow mock for the Remote Client plugin tests.""" + + def __init__(self): + # This avoids using the cli options passed to pytest + sys_argv = [sys.argv[0]] + self._cli_options = get_options(sys_argv)[0] + super().__init__() + PLUGIN_REGISTRY.set_main(self) + + def register_plugin(self, plugin_class): + plugin = PLUGIN_REGISTRY.register_plugin(self, plugin_class) + plugin._register() + return plugin + + @staticmethod + def unregister_plugin(plugin): + assert PLUGIN_REGISTRY.delete_plugin( + plugin.NAME + ), f"{plugin.NAME} not deleted" + plugin._unregister() + + @staticmethod + def get_plugin(plugin_name, error=False): + return PLUGIN_REGISTRY.get_plugin(plugin_name) + + @staticmethod + def is_plugin_available(plugin_name): + return PLUGIN_REGISTRY.is_plugin_available(plugin_name) + + +@pytest.fixture(scope="session") +def docker_compose_file(pytestconfig): + """Override the default docker-compose.yml file.""" + return str(HERE / "docker-compose.yml") + + +@pytest.fixture(scope="session") +def remote_client_id( + ssh_server_addr: typing.Tuple[str, int], remote_client: RemoteClient +) -> typing.Iterator[SpyderRemoteClient]: + """Add the Spyder Remote Client plugin to the registry.""" + ssh_options = { + "host": ssh_server_addr[0], + "port": ssh_server_addr[1], + "username": USERNAME, + "password": PASSWORD, + "platform": "linux", + "known_hosts": None, + } + config_id = str(uuid.uuid4()) + + # Options Required by container widget + remote_client.set_conf( + f"{config_id}/auth_method", AuthenticationMethod.Password + ) + remote_client.set_conf( + f"{config_id}/{AuthenticationMethod.Password}/name", "test-server" + ) + remote_client.set_conf( + f"{config_id}/{AuthenticationMethod.Password}/address", + ssh_server_addr[0], + ) + remote_client.set_conf( + f"{config_id}/{AuthenticationMethod.Password}/username", USERNAME + ) + + try: + remote_client.load_client(options=ssh_options, config_id=config_id) + yield config_id + + finally: + remote_client.on_close(cancellable=False) + + +@pytest.fixture(scope="session") +def remote_client( + ipyconsole_and_remoteclient: tuple[IPythonConsole, RemoteClient], +) -> RemoteClient: + """Start the Spyder Remote Client plugin. + + Yields + ------ + RemoteClient: Spyder Remote Client plugin. + """ + return ipyconsole_and_remoteclient[1] + + +@pytest.fixture(scope="session") +def ipyconsole( + ipyconsole_and_remoteclient: tuple[IPythonConsole, RemoteClient], +) -> IPythonConsole: + """Start the IPython Console plugin. + + Yields + ------ + IPythonConsole: IPython Console plugin. + """ + return ipyconsole_and_remoteclient[0] + + +@pytest.fixture(scope="session") +def ipyconsole_and_remoteclient() -> ( + typing.Iterator[tuple[IPythonConsole, RemoteClient]] +): + """Start the Spyder Remote Client plugin with IPython Console. + + Yields + ------ + tuple: IPython Console and Spyder Remote Client plugins + """ + app = qapplication(translate=False) + app.setQuitOnLastWindowClosed(False) + + window = MainWindowMock() + + os.environ["IPYCONSOLE_TESTING"] = "True" + try: + console = window.register_plugin(IPythonConsole) + remote_client = window.register_plugin(RemoteClient) + + yield console, remote_client + + window.unregister_plugin(console) + window.unregister_plugin(remote_client) + del console, remote_client + finally: + os.environ.pop("IPYCONSOLE_TESTING") + CONF.reset_manager() + PLUGIN_REGISTRY.reset() + if app.instance(): + for widget in app.allWidgets(): + with contextlib.suppress(RuntimeError): + widget.close() + app.quit() + del window + gc.collect() + + +@pytest.fixture(scope="session") +def ssh_server_addr( + docker_ip: str, docker_services: Services +) -> tuple[str, int]: + """Start an SSH server from docker-compose and return its address. + + Args + ---- + docker_ip (str): IP address of the Docker daemon. + docker_services (Services): Docker services. + + Returns + ------- + tuple: IP address and port of the SSH server. + """ + # Build URL to service listening on random port. + # NOTE: This is the service name and port in the docker-compose.yml file. + port = docker_services.port_for("test-spyder-remote-server", 22) + + docker_services.wait_until_responsive( + check=lambda: check_server(ip=docker_ip, port=port), + timeout=30.0, + pause=0.1, + ) + + return docker_ip, port + + +def check_server(ip="127.0.0.1", port=22): + """Check if a server is listening on the given IP and port. + + Args + ---- + ip (str, optional): IP address to check. Defaults to "127.0.0.1". + port (int, optional): Port to check. Defaults to 22. + + Returns + ------- + bool: server is listening on the given IP and port + """ + test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + test_socket.connect((ip, port)) + except OSError: + return False + else: + test_socket.close() + return True + + +def await_future(future: Future[T], timeout: float = 30) -> T: + """Wait for a future to finish or timeout.""" + return future.result(timeout=timeout) diff --git a/spyder/plugins/remoteclient/tests/docker-compose.yml b/spyder/plugins/remoteclient/tests/docker-compose.yml new file mode 100644 index 00000000000..4f470411813 --- /dev/null +++ b/spyder/plugins/remoteclient/tests/docker-compose.yml @@ -0,0 +1,6 @@ +services: + test-spyder-remote-server: + build: . + ports: + - "22" + privileged: true # Required for /usr/sbin/init diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py new file mode 100644 index 00000000000..fd3ff9036e3 --- /dev/null +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -0,0 +1,101 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""IPython console and Spyder Remote Client integration tests.""" + +# Standard library imports + +# Local imports +from spyder.plugins.remoteclient.tests.conftest import await_future + + +def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): + """Starts and stops a kernel on the remote server.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, # longer timeout for installation + ) + + ipyconsole.get_widget().close_client() + + assert ( + await_future(remote_client.get_kernels(remote_client_id), timeout=10) + == [] + ) + + +def test_restart_kernel(ipyconsole, remote_client, remote_client_id, qtbot): + """Test that kernel is restarted correctly.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + # Do an assignment to verify that it's not there after restarting + with qtbot.waitSignal(shell.executed): + shell.execute("a = 10") + + # Restart kernel and wait until it's up again. + # NOTE: We trigger the restart_action instead of calling `restart_kernel` + # directly to also check that that action is working as expected and avoid + # regressions such as spyder-ide/spyder#22084. + shell._prompt_html = None + ipyconsole.get_widget().restart_action.trigger() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=4000, + ) + + assert not shell.is_defined("a") + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + ipyconsole.get_widget().close_client() + + +def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): + """Test that the kernel correctly restarts after a kill.""" + crash_string = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + # Since the heartbeat and the tunnels are running in separate threads, we + # need to make sure that the heartbeat thread has "higher" priority than + # the tunnel thread, otherwise the kernel will be restarted and the tunnels + # recreated before the heartbeat can detect the kernel is dead. + # In the test enviroment, the heartbeat needs to be set to a lower value + # because there are fewer threads running. + shell.kernel_handler.set_time_to_dead(0.2) + + shell._prompt_html = None + with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): + shell.execute(crash_string) + + assert "The kernel died, restarting..." in shell._control.toPlainText() + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + ipyconsole.get_widget().close_client() diff --git a/spyder/plugins/remoteclient/tests/test_plugin.py b/spyder/plugins/remoteclient/tests/test_plugin.py new file mode 100644 index 00000000000..e58411b1d4f --- /dev/null +++ b/spyder/plugins/remoteclient/tests/test_plugin.py @@ -0,0 +1,163 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Tests for the Spyder Remote Client plugin.""" + +# Third party imports +import pytest + +# Local imports +from spyder.plugins.remoteclient.plugin import RemoteClient +from spyder.plugins.remoteclient.tests.conftest import await_future + + +# ============================================================================= +# ---- Tests +# ============================================================================= +class TestNewServer: + """Test the installation of the Spyder Remote Client plugin.""" + + def test_installation( + self, + remote_client: RemoteClient, + remote_client_id: str, + ): + """Test the installation of the Spyder Remote Client plugin.""" + await_future( + remote_client.ensure_remote_server(remote_client_id), + timeout=180, # longer timeout for installation + ) + assert ( + await_future( + remote_client.get_kernels(remote_client_id), + timeout=10, + ) + == [] + ) + + def test_start_kernel_running_server( + self, + remote_client: RemoteClient, + remote_client_id: str, + ): + """Test starting a kernel on a remote server.""" + kernel_info = await_future( + remote_client._start_new_kernel(remote_client_id), + ) + + assert ( + await_future( + remote_client._get_kernel_info( + remote_client_id, + kernel_info["id"], + ), + ) + == kernel_info + ) + + def test_shutdown_kernel( + self, + remote_client: RemoteClient, + remote_client_id: str, + ): + """Test shutting down a kernel on a remote server.""" + kernel_info = await_future( + remote_client.get_kernels(remote_client_id), + timeout=10, + )[0] + + await_future( + remote_client._shutdown_kernel( + remote_client_id, + kernel_info["id"], + ), + ) + + assert ( + await_future( + remote_client.get_kernels(remote_client_id), + ) + == [] + ) + + +class TestNewKerneLAndServer: + """Test the installation of the Spyder Remote Client plugin.""" + + def test_new_kernel( + self, + remote_client: RemoteClient, + remote_client_id: str, + ): + """Test starting a kernel with no remote server installed.""" + kernel_info = await_future( + remote_client._start_new_kernel(remote_client_id), + timeout=180, + ) + self.kernel_id = kernel_info["id"] + assert ( + await_future( + remote_client._get_kernel_info( + remote_client_id, + self.kernel_id, + ), + ) + == kernel_info + ) + + def test_restart_kernel( + self, + remote_client: RemoteClient, + remote_client_id: str, + ): + """Test restarting a kernel on a remote server.""" + kernel_info = await_future( + remote_client.get_kernels(remote_client_id), + timeout=10, + )[0] + + assert await_future( + remote_client._restart_kernel( + remote_client_id, + kernel_info["id"], + ), + ) + + assert ( + await_future( + remote_client._get_kernel_info( + remote_client_id, + kernel_info["id"], + ), + ) + != [] + ) + + def test_restart_server( + self, + remote_client: RemoteClient, + remote_client_id: str, + ): + """Test restarting a remote server.""" + await_future( + remote_client.stop_remote_server(remote_client_id), + ) + + await_future( + remote_client.start_remote_server(remote_client_id), + ) + + assert ( + await_future( + remote_client.get_kernels(remote_client_id), + ) + == [] + ) + + +if __name__ == "__main__": + pytest.main() From caa4831568f5e66c3d3f985ce72a0a658ea64650 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 18 Sep 2024 20:11:04 -0300 Subject: [PATCH 02/29] fix: add missing pytest-docker requirement --- requirements/tests.yml | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements/tests.yml b/requirements/tests.yml index 2edb861eb4c..09899512f0d 100644 --- a/requirements/tests.yml +++ b/requirements/tests.yml @@ -13,6 +13,7 @@ dependencies: - pillow - pytest <8.0 - pytest-cov + - pytest-docker - pytest-lazy-fixture - pytest-mock - pytest-order diff --git a/setup.py b/setup.py index 9e8fdabf95d..b5ad5844124 100644 --- a/setup.py +++ b/setup.py @@ -275,6 +275,7 @@ def run(self): 'pillow', 'pytest<8.0', 'pytest-cov', + 'pytest-docker', 'pytest-lazy-fixture', 'pytest-mock', 'pytest-order', From 255c697d5f62ce140a64ada45aa6ffebb7db33e4 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 01:28:03 -0300 Subject: [PATCH 03/29] fix: add workflow for remote-client and separate from regular tests --- .github/scripts/install.sh | 67 ++++----- .github/scripts/run_tests.sh | 10 +- .github/workflows/test-remoteclient.yml | 160 ++++++++++++++++++++++ runtests.py | 17 ++- spyder/config/base.py | 8 ++ spyder/plugins/ipythonconsole/__init__.py | 11 +- 6 files changed, 232 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/test-remoteclient.yml diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 41cda1bbee2..38da53f04d0 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -61,38 +61,41 @@ else fi -# Install subrepos from source -python -bb -X dev install_dev_repos.py --not-editable --no-install spyder - -# Install boilerplate plugin -pushd spyder/app/tests/spyder-boilerplate -pip install --no-deps -q -e . -popd - -# Install Spyder to test it as if it was properly installed. -python -bb -X dev -m build -python -bb -X dev -m pip install --no-deps dist/spyder*.whl - -# Adjust PATH on Windows so that we can use conda below. This needs to be done -# at this point or the pip slots fail. -if [ "$OS" = "win" ]; then - PATH=/c/Miniconda/Scripts/:$PATH -fi +if [ -z "$SPYDER_TEST_REMOTE_CLIENT" ]; then + + # Install subrepos from source + python -bb -X dev install_dev_repos.py --not-editable --no-install spyder + + # Install boilerplate plugin + pushd spyder/app/tests/spyder-boilerplate + pip install --no-deps -q -e . + popd + + # Install Spyder to test it as if it was properly installed. + python -bb -X dev -m build + python -bb -X dev -m pip install --no-deps dist/spyder*.whl + + # Adjust PATH on Windows so that we can use conda below. This needs to be done + # at this point or the pip slots fail. + if [ "$OS" = "win" ]; then + PATH=/c/Miniconda/Scripts/:$PATH + fi -# Create environment for Jedi environment tests -conda create -n jedi-test-env -q -y python=3.9 flask -install_spyder_kernels jedi-test-env -conda list -n jedi-test-env - -# Create environment to test conda env activation before launching a kernel -conda create -n spytest-ž -q -y -c conda-forge python=3.9 -install_spyder_kernels spytest-ž -conda list -n spytest-ž - -# Install pyenv on Linux systems -if [ "$RUN_SLOW" = "false" ]; then - if [ "$OS" = "linux" ]; then - curl https://pyenv.run | bash - $HOME/.pyenv/bin/pyenv install 3.8.1 + # Create environment for Jedi environment tests + conda create -n jedi-test-env -q -y python=3.9 flask + install_spyder_kernels jedi-test-env + conda list -n jedi-test-env + + # Create environment to test conda env activation before launching a kernel + conda create -n spytest-ž -q -y -c conda-forge python=3.9 + install_spyder_kernels spytest-ž + conda list -n spytest-ž + + # Install pyenv on Linux systems + if [ "$RUN_SLOW" = "false" ]; then + if [ "$OS" = "linux" ]; then + curl https://pyenv.run | bash + $HOME/.pyenv/bin/pyenv install 3.8.1 + fi fi fi diff --git a/.github/scripts/run_tests.sh b/.github/scripts/run_tests.sh index 560a4c94cd8..162449b9781 100755 --- a/.github/scripts/run_tests.sh +++ b/.github/scripts/run_tests.sh @@ -12,8 +12,12 @@ else fi # Run tests -if [ "$OS" = "linux" ]; then - xvfb-run --auto-servernum python runtests.py --color=yes | tee -a pytest_log.txt +if [ "$SPYDER_TEST_REMOTE_CLIENT" = "true" ]; then + python runtests.py --color=yes --remote-client | tee -a pytest_log.txt else - python runtests.py --color=yes | tee -a pytest_log.txt + if [ "$OS" = "linux" ]; then + xvfb-run --auto-servernum python runtests.py --color=yes | tee -a pytest_log.txt + else + python runtests.py --color=yes | tee -a pytest_log.txt + fi fi diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml new file mode 100644 index 00000000000..80af94e0c14 --- /dev/null +++ b/.github/workflows/test-remoteclient.yml @@ -0,0 +1,160 @@ +name: Linux tests + +on: + push: + branches: + - master + - 6.* + - 5.* + paths: + - '.github/scripts/*.sh' + - '.github/workflows/*.yml' + - 'requirements/*.yml' + - '**.bat' + - '**.py' + - '**.sh' + - '!installers-conda/**' + - '!.github/workflows/installers-conda.yml' + - '!.github/workflows/build-subrepos.yml' + + pull_request: + branches: + - master + - 6.* + - 5.* + paths: + - '.github/scripts/*.sh' + - '.github/workflows/*.yml' + - 'requirements/*.yml' + - '**.bat' + - '**.py' + - '**.sh' + - '!installers-conda/**' + - '!.github/workflows/installers-conda.yml' + - '!.github/workflows/build-subrepos.yml' + + workflow_dispatch: + inputs: + ssh: + # github_cli: gh workflow run test-linux.yml --ref -f ssh=true + description: 'Enable ssh debugging' + required: false + default: false + type: boolean + +concurrency: + group: test-remoteclient-${{ github.ref }} + cancel-in-progress: true + +env: + ENABLE_SSH: ${{ github.event_name == 'workflow_dispatch' && inputs.ssh }} + +jobs: + build: + # Use this to disable the workflow + # if: false + name: Linux - Py${{ matrix.PYTHON_VERSION }}, ${{ matrix.INSTALL_TYPE }} + runs-on: ubuntu-20.04 + env: + CI: 'true' + QTCONSOLE_TESTING: 'true' + CODECOV_TOKEN: "56731c25-9b1f-4340-8b58-35739bfbc52d" + OS: 'linux' + PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} + USE_CONDA: ${{ matrix.INSTALL_TYPE == 'conda' }} + SPYDER_TEST_REMOTE_CLIENT: 'true' + strategy: + fail-fast: false + matrix: + INSTALL_TYPE: ['pip', 'conda'] + PYTHON_VERSION: ['3.8', '3.10'] + exclude: + # Only test Python 3.8 with pip because Conda-forge will drop it soon + - INSTALL_TYPE: 'conda' + PYTHON_VERSION: '3.8' + timeout-minutes: 90 + steps: + - name: Setup Remote SSH Connection + if: env.ENABLE_SSH == 'true' + uses: mxschmitt/action-tmate@v3 + timeout-minutes: 60 + with: + detached: true + - name: Checkout Pull Requests + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Checkout Push + if: github.event_name != 'pull_request' + uses: actions/checkout@v4 + - name: Fetch branches + run: git fetch --prune --unshallow + - name: Install dependencies + shell: bash + run: | + sudo apt-get update --fix-missing + sudo apt-get install -qq pyqt5-dev-tools libxcb-xinerama0 xterm --fix-missing + sudo apt-get install -qq docker docker-compose --fix-missing + - name: Cache conda + uses: actions/cache@v4 + env: + # Increase this value to reset cache if requirements/*.txt has not changed + CACHE_NUMBER: 0 + with: + path: ~/conda_pkgs_dir + key: ${{ runner.os }}-cacheconda-install${{ matrix.INSTALL_TYPE }}-${{ matrix.PYTHON_VERSION }}-${{ env.CACHE_NUMBER }}-${{ hashFiles('requirements/*.yml') }} + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-cachepip-install${{ matrix.INSTALL_TYPE }}-${{ env.CACHE_NUMBER }}-${{ hashFiles('setup.py') }} + - name: Create conda test environment + if: env.USE_CONDA == 'true' + uses: mamba-org/setup-micromamba@v1 + with: + environment-file: requirements/main.yml + environment-name: test + cache-downloads: true + create-args: python=${{ matrix.PYTHON_VERSION }} + - name: Create pip test environment + if: env.USE_CONDA != 'true' + uses: mamba-org/setup-micromamba@v1 + with: + environment-name: test + cache-downloads: true + create-args: python=${{ matrix.PYTHON_VERSION }} + condarc: | + channels: + - conda-forge + - name: Install additional dependencies + shell: bash -l {0} + run: bash -l .github/scripts/install.sh + - name: Show conda test environment + if: env.USE_CONDA == 'true' + shell: bash -l {0} + run: | + micromamba info + micromamba list + - name: Show pip test environment + if: env.USE_CONDA != 'true' + shell: bash -l {0} + run: | + micromamba info + micromamba list + pip list + - name: Run tests + shell: bash -l {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + rm -f pytest_log.txt # Must remove any log file from a previous run + .github/scripts/run_tests.sh || \ + .github/scripts/run_tests.sh || \ + .github/scripts/run_tests.sh || \ + .github/scripts/run_tests.sh + - name: Coverage + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: false + verbose: true diff --git a/runtests.py b/runtests.py index 0c265c539b4..aac30513ccd 100644 --- a/runtests.py +++ b/runtests.py @@ -29,9 +29,9 @@ RUN_SLOW = os.environ.get('RUN_SLOW', None) == 'true' -def run_pytest(run_slow=False, extra_args=None): +def run_pytest(run_slow=False, extra_args=None, remoteclient=False): """Run pytest tests for Spyder.""" - # Be sure to ignore subrepos + # Be sure to ignore subrepos and remoteclient plugin pytest_args = ['-vv', '-rw', '--durations=10', '--ignore=./external-deps', '-W ignore::UserWarning', '--timeout=120', '--timeout_method=thread'] @@ -49,6 +49,12 @@ def run_pytest(run_slow=False, extra_args=None): if extra_args: pytest_args += extra_args + if remoteclient: + pytest_args += ['./spyder/plugins/remoteclient'] + os.environ["SPYDER_TEST_REMOTE_CLIENT"] = "true" + else: + pytest_args += ['--ignore=remoteclient'] + print("Pytest Arguments: " + str(pytest_args)) errno = pytest.main(pytest_args) @@ -62,12 +68,15 @@ def run_pytest(run_slow=False, extra_args=None): def main(): """Parse args then run the pytest suite for Spyder.""" test_parser = argparse.ArgumentParser( - usage='python runtests.py [-h] [--run-slow] [pytest_args]', + usage='python runtests.py [-h] [--run-slow] [--remote-client][pytest_args]', description="Helper script to run Spyder's test suite") test_parser.add_argument('--run-slow', action='store_true', default=False, help='Run the slow tests') + test_parser.add_argument("--remote-client", action="store_true", + default=False, help="Run the remote client tests") test_args, pytest_args = test_parser.parse_known_args() - run_pytest(run_slow=test_args.run_slow, extra_args=pytest_args) + run_pytest(run_slow=test_args.run_slow, extra_args=pytest_args, + remoteclient=test_args.remote_client) if __name__ == '__main__': diff --git a/spyder/config/base.py b/spyder/config/base.py index 19aa0fc2944..1d742d2fcec 100644 --- a/spyder/config/base.py +++ b/spyder/config/base.py @@ -58,6 +58,14 @@ def running_under_pytest(): """ return bool(os.environ.get('SPYDER_PYTEST')) +def running_under_remoteclient_tests(): + """ + Return True if currently running under remoteclient tests. + + This function is used to do some adjustment for testing. The environment + variable SPYDER_REMOTECLIENT_TESTS is defined in conftest.py. + """ + return bool(os.environ.get("SPYDER_REMOTECLIENT_TESTS")) def running_in_ci(): """Return True if currently running under CI.""" diff --git a/spyder/plugins/ipythonconsole/__init__.py b/spyder/plugins/ipythonconsole/__init__.py index 60d63447d0c..a7365243fc2 100644 --- a/spyder/plugins/ipythonconsole/__init__.py +++ b/spyder/plugins/ipythonconsole/__init__.py @@ -11,7 +11,12 @@ IPython Console plugin based on QtConsole """ -from spyder.config.base import is_stable_version, running_under_pytest +from spyder.config.base import ( + is_stable_version, + running_under_pytest, + running_under_remoteclient_tests, +) + # Use this variable, which corresponds to the html dash symbol, for any command # that requires a dash below. That way users will be able to copy/paste @@ -20,7 +25,9 @@ # Required version of Spyder-kernels SPYDER_KERNELS_MIN_VERSION = ( - '3.0.0' if not running_under_pytest() else '3.1.0.dev0' + "3.0.0" + if not running_under_pytest() or running_under_remoteclient_tests() + else "3.1.0.dev0" ) SPYDER_KERNELS_MAX_VERSION = ( '3.1.0' if not running_under_pytest() else '4.0.0' From cc888de0c24096d1991b6ed43dee3ae96aa6afa8 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 01:30:22 -0300 Subject: [PATCH 04/29] fix: update wrong remoteclient workflow tests name --- .github/workflows/test-remoteclient.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index 80af94e0c14..e71f0485fea 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -1,4 +1,4 @@ -name: Linux tests +name: Remote Client Tests on: push: From 67dbceead0d85349ccc1c1650300ed970ced1797 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 01:34:41 -0300 Subject: [PATCH 05/29] fix: update typings for python 3.8 compatibility --- spyder/plugins/remoteclient/tests/conftest.py | 8 ++++---- spyder/plugins/remoteclient/tests/test_ipythonconsole.py | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index 936c9c83f74..40e2d4e1512 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -117,7 +117,7 @@ def remote_client_id( @pytest.fixture(scope="session") def remote_client( - ipyconsole_and_remoteclient: tuple[IPythonConsole, RemoteClient], + ipyconsole_and_remoteclient: typing.Tuple[IPythonConsole, RemoteClient], ) -> RemoteClient: """Start the Spyder Remote Client plugin. @@ -130,7 +130,7 @@ def remote_client( @pytest.fixture(scope="session") def ipyconsole( - ipyconsole_and_remoteclient: tuple[IPythonConsole, RemoteClient], + ipyconsole_and_remoteclient: typing.Tuple[IPythonConsole, RemoteClient], ) -> IPythonConsole: """Start the IPython Console plugin. @@ -143,7 +143,7 @@ def ipyconsole( @pytest.fixture(scope="session") def ipyconsole_and_remoteclient() -> ( - typing.Iterator[tuple[IPythonConsole, RemoteClient]] + typing.Iterator[typing.Tuple[IPythonConsole, RemoteClient]] ): """Start the Spyder Remote Client plugin with IPython Console. @@ -182,7 +182,7 @@ def ipyconsole_and_remoteclient() -> ( @pytest.fixture(scope="session") def ssh_server_addr( docker_ip: str, docker_services: Services -) -> tuple[str, int]: +) -> typing.Tuple[str, int]: """Start an SSH server from docker-compose and return its address. Args diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py index fd3ff9036e3..c9bb68b6321 100644 --- a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -7,7 +7,8 @@ """IPython console and Spyder Remote Client integration tests.""" -# Standard library imports +# Third party imports +import pytest # Local imports from spyder.plugins.remoteclient.tests.conftest import await_future @@ -99,3 +100,7 @@ def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) ipyconsole.get_widget().close_client() + + +if __name__ == "__main__": + pytest.main() From 6554d9eda17e8b6471c0181a508ccf0c42678bbf Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 01:42:18 -0300 Subject: [PATCH 06/29] fix: wrong remoteclient plugin path --- runtests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtests.py b/runtests.py index aac30513ccd..fafdaf48f7c 100644 --- a/runtests.py +++ b/runtests.py @@ -53,7 +53,7 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): pytest_args += ['./spyder/plugins/remoteclient'] os.environ["SPYDER_TEST_REMOTE_CLIENT"] = "true" else: - pytest_args += ['--ignore=remoteclient'] + pytest_args += ['--ignore=./spyder/plugins/remoteclient'] print("Pytest Arguments: " + str(pytest_args)) errno = pytest.main(pytest_args) From bde70f8aa4670ac16dc8fb63b53ba54d5556bd63 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 01:43:02 -0300 Subject: [PATCH 07/29] fix: add annotations import for typings --- spyder/plugins/remoteclient/tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index 40e2d4e1512..741a25ab90d 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -7,6 +7,7 @@ """Fixtures for the Spyder Remote Client plugin tests.""" +from __future__ import annotations from concurrent.futures import Future import contextlib import gc From 1db948d47c04a994a473811970c8ce89c341b4ba Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 10:04:37 -0300 Subject: [PATCH 08/29] fix: call pytest directly in workflow --- .github/workflows/test-remoteclient.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index e71f0485fea..551826290ff 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -148,11 +148,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - rm -f pytest_log.txt # Must remove any log file from a previous run - .github/scripts/run_tests.sh || \ - .github/scripts/run_tests.sh || \ - .github/scripts/run_tests.sh || \ - .github/scripts/run_tests.sh + rm -f pytest_log.txt + pytest --color=yes -W ignore::UserWarning --cov=spyder --no-cov-on-fai --cov-report=xml ./spyder/plugins/remoteclient | tee -a pytest_log.txt - name: Coverage uses: codecov/codecov-action@v4 with: From 47db61119ebb4a969a0f591de68cdf6fe6f8dfe7 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Wed, 25 Sep 2024 11:57:32 -0300 Subject: [PATCH 09/29] fix: typo on pytest argument with remote client tests workflow --- .github/workflows/test-remoteclient.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index 551826290ff..7a7f9749dae 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -149,7 +149,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | rm -f pytest_log.txt - pytest --color=yes -W ignore::UserWarning --cov=spyder --no-cov-on-fai --cov-report=xml ./spyder/plugins/remoteclient | tee -a pytest_log.txt + pytest --color=yes -W ignore::UserWarning --cov=spyder --no-cov-on-fail --cov-report=xml ./spyder/plugins/remoteclient | tee -a pytest_log.txt - name: Coverage uses: codecov/codecov-action@v4 with: From 0380a6b0e357a9979dadeca596d8e8bb25cf1a42 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 17:38:52 -0300 Subject: [PATCH 10/29] add pytest-docker requirement to binder --- binder/environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/binder/environment.yml b/binder/environment.yml index d4c3e6898aa..6fd3de61b01 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -70,6 +70,7 @@ dependencies: - pillow - pytest <8.0 - pytest-cov +- pytest-docker - pytest-lazy-fixture - pytest-mock - pytest-order From 6add6599d2a9a93fb9ab3f60b66840cc04fe0b84 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 17:59:12 -0300 Subject: [PATCH 11/29] fix: remove pytest-docker from dependencies and install it in install.sh --- .github/scripts/install.sh | 5 +++-- binder/environment.yml | 1 - requirements/tests.yml | 1 - setup.py | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 38da53f04d0..08988724091 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -61,8 +61,9 @@ else fi -if [ -z "$SPYDER_TEST_REMOTE_CLIENT" ]; then - +if [ "$SPYDER_TEST_REMOTE_CLIENT" = "true" ]; then + pip install pytest-docker +else # Install subrepos from source python -bb -X dev install_dev_repos.py --not-editable --no-install spyder diff --git a/binder/environment.yml b/binder/environment.yml index 6fd3de61b01..d4c3e6898aa 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -70,7 +70,6 @@ dependencies: - pillow - pytest <8.0 - pytest-cov -- pytest-docker - pytest-lazy-fixture - pytest-mock - pytest-order diff --git a/requirements/tests.yml b/requirements/tests.yml index 09899512f0d..2edb861eb4c 100644 --- a/requirements/tests.yml +++ b/requirements/tests.yml @@ -13,7 +13,6 @@ dependencies: - pillow - pytest <8.0 - pytest-cov - - pytest-docker - pytest-lazy-fixture - pytest-mock - pytest-order diff --git a/setup.py b/setup.py index b5ad5844124..9e8fdabf95d 100644 --- a/setup.py +++ b/setup.py @@ -275,7 +275,6 @@ def run(self): 'pillow', 'pytest<8.0', 'pytest-cov', - 'pytest-docker', 'pytest-lazy-fixture', 'pytest-mock', 'pytest-order', From b1b161165f2aec30bc8e27f08eeda5a815e5dd45 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Mon, 30 Sep 2024 18:01:57 -0300 Subject: [PATCH 12/29] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- .github/workflows/test-remoteclient.yml | 5 ++--- spyder/config/base.py | 4 ++-- spyder/plugins/ipythonconsole/__init__.py | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index 7a7f9749dae..fcf2eac1555 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -5,7 +5,6 @@ on: branches: - master - 6.* - - 5.* paths: - '.github/scripts/*.sh' - '.github/workflows/*.yml' @@ -21,7 +20,6 @@ on: branches: - master - 6.* - - 5.* paths: - '.github/scripts/*.sh' - '.github/workflows/*.yml' @@ -67,7 +65,7 @@ jobs: fail-fast: false matrix: INSTALL_TYPE: ['pip', 'conda'] - PYTHON_VERSION: ['3.8', '3.10'] + PYTHON_VERSION: ['3.8', '3.12'] exclude: # Only test Python 3.8 with pip because Conda-forge will drop it soon - INSTALL_TYPE: 'conda' @@ -121,6 +119,7 @@ jobs: if: env.USE_CONDA != 'true' uses: mamba-org/setup-micromamba@v1 with: + micromamba-version: '1.5.10-0' environment-name: test cache-downloads: true create-args: python=${{ matrix.PYTHON_VERSION }} diff --git a/spyder/config/base.py b/spyder/config/base.py index 1d742d2fcec..ec67a326b96 100644 --- a/spyder/config/base.py +++ b/spyder/config/base.py @@ -58,9 +58,9 @@ def running_under_pytest(): """ return bool(os.environ.get('SPYDER_PYTEST')) -def running_under_remoteclient_tests(): +def running_remoteclient_tests(): """ - Return True if currently running under remoteclient tests. + Return True if currently running the remoteclient tests. This function is used to do some adjustment for testing. The environment variable SPYDER_REMOTECLIENT_TESTS is defined in conftest.py. diff --git a/spyder/plugins/ipythonconsole/__init__.py b/spyder/plugins/ipythonconsole/__init__.py index a7365243fc2..3f55d7f7ada 100644 --- a/spyder/plugins/ipythonconsole/__init__.py +++ b/spyder/plugins/ipythonconsole/__init__.py @@ -14,7 +14,7 @@ from spyder.config.base import ( is_stable_version, running_under_pytest, - running_under_remoteclient_tests, + running_remoteclient_tests, ) @@ -26,7 +26,7 @@ # Required version of Spyder-kernels SPYDER_KERNELS_MIN_VERSION = ( "3.0.0" - if not running_under_pytest() or running_under_remoteclient_tests() + if not running_under_pytest() or running_remoteclient_tests() else "3.1.0.dev0" ) SPYDER_KERNELS_MAX_VERSION = ( From c8fc1f748ffa3ef456dea1d0c5f44d840332d200 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Mon, 30 Sep 2024 18:10:14 -0300 Subject: [PATCH 13/29] fix: set micromamba version to conda env test Co-authored-by: Carlos Cordoba --- .github/workflows/test-remoteclient.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index fcf2eac1555..8cdc2f7ae73 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -111,6 +111,7 @@ jobs: if: env.USE_CONDA == 'true' uses: mamba-org/setup-micromamba@v1 with: + micromamba-version: '1.5.10-0' environment-file: requirements/main.yml environment-name: test cache-downloads: true From 4440f87e51daf02fc23607b55f68e916b0594e21 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 18:25:44 -0300 Subject: [PATCH 14/29] fix: use xvfb-run in remoteclient tests --- .github/scripts/run_tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/run_tests.sh b/.github/scripts/run_tests.sh index 162449b9781..d80f5de849f 100755 --- a/.github/scripts/run_tests.sh +++ b/.github/scripts/run_tests.sh @@ -13,7 +13,7 @@ fi # Run tests if [ "$SPYDER_TEST_REMOTE_CLIENT" = "true" ]; then - python runtests.py --color=yes --remote-client | tee -a pytest_log.txt + xvfb-run --auto-servernum python runtests.py --color=yes --remote-client | tee -a pytest_log.txt else if [ "$OS" = "linux" ]; then xvfb-run --auto-servernum python runtests.py --color=yes | tee -a pytest_log.txt From 896584553c452db00afeb22f77d76b3e39e6420d Mon Sep 17 00:00:00 2001 From: Hendrik Date: Mon, 30 Sep 2024 18:27:10 -0300 Subject: [PATCH 15/29] fix: use run_test script to start remoteclient tests Co-authored-by: Carlos Cordoba --- .github/workflows/test-remoteclient.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index 8cdc2f7ae73..81edf0f049d 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -149,7 +149,11 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | rm -f pytest_log.txt - pytest --color=yes -W ignore::UserWarning --cov=spyder --no-cov-on-fail --cov-report=xml ./spyder/plugins/remoteclient | tee -a pytest_log.txt + rm -f pytest_log.txt # Must remove any log file from a previous run + .github/scripts/run_tests.sh || \ + .github/scripts/run_tests.sh || \ + .github/scripts/run_tests.sh || \ + .github/scripts/run_tests.sh - name: Coverage uses: codecov/codecov-action@v4 with: From 7a7eb60dceb382208dfc43b7be15a8fe2a554687 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 18:36:33 -0300 Subject: [PATCH 16/29] fix: start docker daemon in remoteclient test enviroment --- .github/workflows/test-remoteclient.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index 81edf0f049d..bda0feaa096 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -94,6 +94,7 @@ jobs: sudo apt-get update --fix-missing sudo apt-get install -qq pyqt5-dev-tools libxcb-xinerama0 xterm --fix-missing sudo apt-get install -qq docker docker-compose --fix-missing + sudo service docker start - name: Cache conda uses: actions/cache@v4 env: From b33d499bb8028a419b4444a7e73e206163b3764b Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 18:39:17 -0300 Subject: [PATCH 17/29] fix: install dependencies and spyder in remoteclient tests --- .github/scripts/install.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 08988724091..a4f5d729c70 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -61,21 +61,22 @@ else fi +# Install subrepos from source +python -bb -X dev install_dev_repos.py --not-editable --no-install spyder + +# Install Spyder to test it as if it was properly installed. +python -bb -X dev -m build +python -bb -X dev -m pip install --no-deps dist/spyder*.whl + if [ "$SPYDER_TEST_REMOTE_CLIENT" = "true" ]; then pip install pytest-docker else - # Install subrepos from source - python -bb -X dev install_dev_repos.py --not-editable --no-install spyder # Install boilerplate plugin pushd spyder/app/tests/spyder-boilerplate pip install --no-deps -q -e . popd - # Install Spyder to test it as if it was properly installed. - python -bb -X dev -m build - python -bb -X dev -m pip install --no-deps dist/spyder*.whl - # Adjust PATH on Windows so that we can use conda below. This needs to be done # at this point or the pip slots fail. if [ "$OS" = "win" ]; then From 676d35067c98439b6e9373a4a4673d80bb26cfdf Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 19:02:45 -0300 Subject: [PATCH 18/29] fix: wrong variable name in running_remoteclient_tests --- spyder/config/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/config/base.py b/spyder/config/base.py index ec67a326b96..bc782a32b3b 100644 --- a/spyder/config/base.py +++ b/spyder/config/base.py @@ -63,9 +63,9 @@ def running_remoteclient_tests(): Return True if currently running the remoteclient tests. This function is used to do some adjustment for testing. The environment - variable SPYDER_REMOTECLIENT_TESTS is defined in conftest.py. + variable SPYDER_TEST_REMOTE_CLIENT is 'true' in conftest.py. """ - return bool(os.environ.get("SPYDER_REMOTECLIENT_TESTS")) + return bool(os.environ.get("SPYDER_TEST_REMOTE_CLIENT") == "true") def running_in_ci(): """Return True if currently running under CI.""" From 3191a4329e52f1da5d4641800ee98e0b68903007 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 19:11:30 -0300 Subject: [PATCH 19/29] fix: docker and docker-compose already installed in github images --- .github/workflows/test-remoteclient.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test-remoteclient.yml b/.github/workflows/test-remoteclient.yml index bda0feaa096..9ea06d22643 100644 --- a/.github/workflows/test-remoteclient.yml +++ b/.github/workflows/test-remoteclient.yml @@ -93,8 +93,6 @@ jobs: run: | sudo apt-get update --fix-missing sudo apt-get install -qq pyqt5-dev-tools libxcb-xinerama0 xterm --fix-missing - sudo apt-get install -qq docker docker-compose --fix-missing - sudo service docker start - name: Cache conda uses: actions/cache@v4 env: From af4a6ecf6cc378bc1ef56280e527710490ee5987 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 19:51:12 -0300 Subject: [PATCH 20/29] feat: add flaky to rerun tests for installation and container build timeouts --- spyder/plugins/remoteclient/tests/test_ipythonconsole.py | 2 ++ spyder/plugins/remoteclient/tests/test_plugin.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py index c9bb68b6321..f5f17c393d2 100644 --- a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -9,11 +9,13 @@ # Third party imports import pytest +from flaky import flaky # Local imports from spyder.plugins.remoteclient.tests.conftest import await_future +@flaky(max_runs=3, min_passes=1) def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): """Starts and stops a kernel on the remote server.""" remote_client.create_ipyclient_for_server(remote_client_id) diff --git a/spyder/plugins/remoteclient/tests/test_plugin.py b/spyder/plugins/remoteclient/tests/test_plugin.py index e58411b1d4f..cd097522625 100644 --- a/spyder/plugins/remoteclient/tests/test_plugin.py +++ b/spyder/plugins/remoteclient/tests/test_plugin.py @@ -9,6 +9,7 @@ # Third party imports import pytest +from flaky import flaky # Local imports from spyder.plugins.remoteclient.plugin import RemoteClient @@ -21,6 +22,7 @@ class TestNewServer: """Test the installation of the Spyder Remote Client plugin.""" + @flaky(max_runs=3, min_passes=1) def test_installation( self, remote_client: RemoteClient, @@ -88,6 +90,7 @@ def test_shutdown_kernel( class TestNewKerneLAndServer: """Test the installation of the Spyder Remote Client plugin.""" + @flaky(max_runs=3, min_passes=1) def test_new_kernel( self, remote_client: RemoteClient, From a1dc91a117a68820d95d6bbd9cd962bcd105e525 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 30 Sep 2024 18:02:57 -0500 Subject: [PATCH 21/29] Testing: Don't split test suite for remote client tests --- conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 46d649eb933..8fba8c85550 100644 --- a/conftest.py +++ b/conftest.py @@ -74,7 +74,9 @@ def pytest_collection_modifyitems(config, items): # This provides a more balanced partitioning of our test suite (in terms of # necessary time to run it) between the slow and fast slots we have on CIs. slow_items = [] - if os.environ.get('CI'): + if os.environ.get("CI") and not os.environ.get( + "SPYDER_TEST_REMOTE_CLIENT" + ): slow_items = [ item for item in items if 'test_mainwindow' in item.nodeid ] From dd89fc791e364bf3b3b6dd6b9fb2b228ef46195c Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 20:26:57 -0300 Subject: [PATCH 22/29] feat: set one docker per class --- runtests.py | 1 + spyder/plugins/remoteclient/tests/conftest.py | 4 +- .../remoteclient/tests/test_ipythonconsole.py | 176 +++++++++--------- 3 files changed, 91 insertions(+), 90 deletions(-) diff --git a/runtests.py b/runtests.py index fafdaf48f7c..41d9bb8304a 100644 --- a/runtests.py +++ b/runtests.py @@ -50,6 +50,7 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): pytest_args += extra_args if remoteclient: + pytest_args += ['--container-scope=class'] pytest_args += ['./spyder/plugins/remoteclient'] os.environ["SPYDER_TEST_REMOTE_CLIENT"] = "true" else: diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index 741a25ab90d..b76606ed3cc 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -78,7 +78,7 @@ def docker_compose_file(pytestconfig): return str(HERE / "docker-compose.yml") -@pytest.fixture(scope="session") +@pytest.fixture(scope="class") def remote_client_id( ssh_server_addr: typing.Tuple[str, int], remote_client: RemoteClient ) -> typing.Iterator[SpyderRemoteClient]: @@ -180,7 +180,7 @@ def ipyconsole_and_remoteclient() -> ( gc.collect() -@pytest.fixture(scope="session") +@pytest.fixture(scope="class") def ssh_server_addr( docker_ip: str, docker_services: Services ) -> typing.Tuple[str, int]: diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py index f5f17c393d2..aac6158428a 100644 --- a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -14,94 +14,94 @@ # Local imports from spyder.plugins.remoteclient.tests.conftest import await_future - -@flaky(max_runs=3, min_passes=1) -def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): - """Starts and stops a kernel on the remote server.""" - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, # longer timeout for installation - ) - - ipyconsole.get_widget().close_client() - - assert ( - await_future(remote_client.get_kernels(remote_client_id), timeout=10) - == [] - ) - - -def test_restart_kernel(ipyconsole, remote_client, remote_client_id, qtbot): - """Test that kernel is restarted correctly.""" - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, - ) - - # Do an assignment to verify that it's not there after restarting - with qtbot.waitSignal(shell.executed): - shell.execute("a = 10") - - # Restart kernel and wait until it's up again. - # NOTE: We trigger the restart_action instead of calling `restart_kernel` - # directly to also check that that action is working as expected and avoid - # regressions such as spyder-ide/spyder#22084. - shell._prompt_html = None - ipyconsole.get_widget().restart_action.trigger() - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=4000, - ) - - assert not shell.is_defined("a") - - with qtbot.waitSignal(shell.executed): - shell.execute("b = 10") - - # Make sure that kernel is responsive after restart - qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) - - ipyconsole.get_widget().close_client() - - -def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): - """Test that the kernel correctly restarts after a kill.""" - crash_string = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" - - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, - ) - - # Since the heartbeat and the tunnels are running in separate threads, we - # need to make sure that the heartbeat thread has "higher" priority than - # the tunnel thread, otherwise the kernel will be restarted and the tunnels - # recreated before the heartbeat can detect the kernel is dead. - # In the test enviroment, the heartbeat needs to be set to a lower value - # because there are fewer threads running. - shell.kernel_handler.set_time_to_dead(0.2) - - shell._prompt_html = None - with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): - shell.execute(crash_string) - - assert "The kernel died, restarting..." in shell._control.toPlainText() - - with qtbot.waitSignal(shell.executed): - shell.execute("b = 10") - - # Make sure that kernel is responsive after restart - qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) - - ipyconsole.get_widget().close_client() +class TestIpythonConsole: + @flaky(max_runs=3, min_passes=1) + def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): + """Starts and stops a kernel on the remote server.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, # longer timeout for installation + ) + + ipyconsole.get_widget().close_client() + + assert ( + await_future(remote_client.get_kernels(remote_client_id), timeout=10) + == [] + ) + + + def test_restart_kernel(ipyconsole, remote_client, remote_client_id, qtbot): + """Test that kernel is restarted correctly.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + # Do an assignment to verify that it's not there after restarting + with qtbot.waitSignal(shell.executed): + shell.execute("a = 10") + + # Restart kernel and wait until it's up again. + # NOTE: We trigger the restart_action instead of calling `restart_kernel` + # directly to also check that that action is working as expected and avoid + # regressions such as spyder-ide/spyder#22084. + shell._prompt_html = None + ipyconsole.get_widget().restart_action.trigger() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=4000, + ) + + assert not shell.is_defined("a") + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + ipyconsole.get_widget().close_client() + + + def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): + """Test that the kernel correctly restarts after a kill.""" + crash_string = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + # Since the heartbeat and the tunnels are running in separate threads, we + # need to make sure that the heartbeat thread has "higher" priority than + # the tunnel thread, otherwise the kernel will be restarted and the tunnels + # recreated before the heartbeat can detect the kernel is dead. + # In the test enviroment, the heartbeat needs to be set to a lower value + # because there are fewer threads running. + shell.kernel_handler.set_time_to_dead(0.2) + + shell._prompt_html = None + with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): + shell.execute(crash_string) + + assert "The kernel died, restarting..." in shell._control.toPlainText() + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + ipyconsole.get_widget().close_client() if __name__ == "__main__": From 0ad7196f023fa9820988d80e8b9907f56e829120 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 20:36:59 -0300 Subject: [PATCH 23/29] Revert "feat: set one docker per class" This reverts commit dd89fc791e364bf3b3b6dd6b9fb2b228ef46195c. --- runtests.py | 1 - spyder/plugins/remoteclient/tests/conftest.py | 4 +- .../remoteclient/tests/test_ipythonconsole.py | 176 +++++++++--------- 3 files changed, 90 insertions(+), 91 deletions(-) diff --git a/runtests.py b/runtests.py index 41d9bb8304a..fafdaf48f7c 100644 --- a/runtests.py +++ b/runtests.py @@ -50,7 +50,6 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): pytest_args += extra_args if remoteclient: - pytest_args += ['--container-scope=class'] pytest_args += ['./spyder/plugins/remoteclient'] os.environ["SPYDER_TEST_REMOTE_CLIENT"] = "true" else: diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index b76606ed3cc..741a25ab90d 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -78,7 +78,7 @@ def docker_compose_file(pytestconfig): return str(HERE / "docker-compose.yml") -@pytest.fixture(scope="class") +@pytest.fixture(scope="session") def remote_client_id( ssh_server_addr: typing.Tuple[str, int], remote_client: RemoteClient ) -> typing.Iterator[SpyderRemoteClient]: @@ -180,7 +180,7 @@ def ipyconsole_and_remoteclient() -> ( gc.collect() -@pytest.fixture(scope="class") +@pytest.fixture(scope="session") def ssh_server_addr( docker_ip: str, docker_services: Services ) -> typing.Tuple[str, int]: diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py index aac6158428a..f5f17c393d2 100644 --- a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -14,94 +14,94 @@ # Local imports from spyder.plugins.remoteclient.tests.conftest import await_future -class TestIpythonConsole: - @flaky(max_runs=3, min_passes=1) - def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): - """Starts and stops a kernel on the remote server.""" - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, # longer timeout for installation - ) - - ipyconsole.get_widget().close_client() - - assert ( - await_future(remote_client.get_kernels(remote_client_id), timeout=10) - == [] - ) - - - def test_restart_kernel(ipyconsole, remote_client, remote_client_id, qtbot): - """Test that kernel is restarted correctly.""" - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, - ) - - # Do an assignment to verify that it's not there after restarting - with qtbot.waitSignal(shell.executed): - shell.execute("a = 10") - - # Restart kernel and wait until it's up again. - # NOTE: We trigger the restart_action instead of calling `restart_kernel` - # directly to also check that that action is working as expected and avoid - # regressions such as spyder-ide/spyder#22084. - shell._prompt_html = None - ipyconsole.get_widget().restart_action.trigger() - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=4000, - ) - - assert not shell.is_defined("a") - - with qtbot.waitSignal(shell.executed): - shell.execute("b = 10") - - # Make sure that kernel is responsive after restart - qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) - - ipyconsole.get_widget().close_client() - - - def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): - """Test that the kernel correctly restarts after a kill.""" - crash_string = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" - - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, - ) - - # Since the heartbeat and the tunnels are running in separate threads, we - # need to make sure that the heartbeat thread has "higher" priority than - # the tunnel thread, otherwise the kernel will be restarted and the tunnels - # recreated before the heartbeat can detect the kernel is dead. - # In the test enviroment, the heartbeat needs to be set to a lower value - # because there are fewer threads running. - shell.kernel_handler.set_time_to_dead(0.2) - - shell._prompt_html = None - with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): - shell.execute(crash_string) - - assert "The kernel died, restarting..." in shell._control.toPlainText() - - with qtbot.waitSignal(shell.executed): - shell.execute("b = 10") - - # Make sure that kernel is responsive after restart - qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) - - ipyconsole.get_widget().close_client() + +@flaky(max_runs=3, min_passes=1) +def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): + """Starts and stops a kernel on the remote server.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, # longer timeout for installation + ) + + ipyconsole.get_widget().close_client() + + assert ( + await_future(remote_client.get_kernels(remote_client_id), timeout=10) + == [] + ) + + +def test_restart_kernel(ipyconsole, remote_client, remote_client_id, qtbot): + """Test that kernel is restarted correctly.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + # Do an assignment to verify that it's not there after restarting + with qtbot.waitSignal(shell.executed): + shell.execute("a = 10") + + # Restart kernel and wait until it's up again. + # NOTE: We trigger the restart_action instead of calling `restart_kernel` + # directly to also check that that action is working as expected and avoid + # regressions such as spyder-ide/spyder#22084. + shell._prompt_html = None + ipyconsole.get_widget().restart_action.trigger() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=4000, + ) + + assert not shell.is_defined("a") + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + ipyconsole.get_widget().close_client() + + +def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): + """Test that the kernel correctly restarts after a kill.""" + crash_string = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + # Since the heartbeat and the tunnels are running in separate threads, we + # need to make sure that the heartbeat thread has "higher" priority than + # the tunnel thread, otherwise the kernel will be restarted and the tunnels + # recreated before the heartbeat can detect the kernel is dead. + # In the test enviroment, the heartbeat needs to be set to a lower value + # because there are fewer threads running. + shell.kernel_handler.set_time_to_dead(0.2) + + shell._prompt_html = None + with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): + shell.execute(crash_string) + + assert "The kernel died, restarting..." in shell._control.toPlainText() + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + ipyconsole.get_widget().close_client() if __name__ == "__main__": From c44f8ab2c3ea1ca7ede6f5e3fdbd530ee7899a00 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Mon, 30 Sep 2024 20:43:29 -0300 Subject: [PATCH 24/29] fix: ignore last_activity from kernel info --- .../plugins/remoteclient/tests/test_plugin.py | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/spyder/plugins/remoteclient/tests/test_plugin.py b/spyder/plugins/remoteclient/tests/test_plugin.py index cd097522625..019e7f408d0 100644 --- a/spyder/plugins/remoteclient/tests/test_plugin.py +++ b/spyder/plugins/remoteclient/tests/test_plugin.py @@ -47,20 +47,22 @@ def test_start_kernel_running_server( remote_client_id: str, ): """Test starting a kernel on a remote server.""" - kernel_info = await_future( + started_kernel_info = await_future( remote_client._start_new_kernel(remote_client_id), ) - assert ( - await_future( - remote_client._get_kernel_info( - remote_client_id, - kernel_info["id"], - ), - ) - == kernel_info + current_kernel_info = await_future( + remote_client._get_kernel_info( + remote_client_id, + started_kernel_info["id"], + ), ) + started_kernel_info.pop("last_activity") + current_kernel_info.pop("last_activity") + + assert started_kernel_info == current_kernel_info + def test_shutdown_kernel( self, remote_client: RemoteClient, @@ -97,21 +99,23 @@ def test_new_kernel( remote_client_id: str, ): """Test starting a kernel with no remote server installed.""" - kernel_info = await_future( + started_kernel_info = await_future( remote_client._start_new_kernel(remote_client_id), timeout=180, ) - self.kernel_id = kernel_info["id"] - assert ( - await_future( - remote_client._get_kernel_info( - remote_client_id, - self.kernel_id, - ), - ) - == kernel_info + + current_kernel_info = await_future( + remote_client._get_kernel_info( + remote_client_id, + started_kernel_info["id"], + ), ) + started_kernel_info.pop("last_activity") + current_kernel_info.pop("last_activity") + + assert started_kernel_info == current_kernel_info + def test_restart_kernel( self, remote_client: RemoteClient, From e8b147b2673990220347ed760300b4ecaade8e39 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sat, 5 Oct 2024 14:52:08 -0300 Subject: [PATCH 25/29] fix: apply revisions --- runtests.py | 2 +- spyder/config/base.py | 2 + spyder/plugins/remoteclient/tests/conftest.py | 60 ++++++++++--------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/runtests.py b/runtests.py index fafdaf48f7c..c1fb894975e 100644 --- a/runtests.py +++ b/runtests.py @@ -68,7 +68,7 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): def main(): """Parse args then run the pytest suite for Spyder.""" test_parser = argparse.ArgumentParser( - usage='python runtests.py [-h] [--run-slow] [--remote-client][pytest_args]', + usage='python runtests.py [-h] [--run-slow] [--remote-client] [pytest_args]', description="Helper script to run Spyder's test suite") test_parser.add_argument('--run-slow', action='store_true', default=False, help='Run the slow tests') diff --git a/spyder/config/base.py b/spyder/config/base.py index bc782a32b3b..6c7f1e60f33 100644 --- a/spyder/config/base.py +++ b/spyder/config/base.py @@ -58,6 +58,7 @@ def running_under_pytest(): """ return bool(os.environ.get('SPYDER_PYTEST')) + def running_remoteclient_tests(): """ Return True if currently running the remoteclient tests. @@ -67,6 +68,7 @@ def running_remoteclient_tests(): """ return bool(os.environ.get("SPYDER_TEST_REMOTE_CLIENT") == "true") + def running_in_ci(): """Return True if currently running under CI.""" return bool(os.environ.get('CI')) diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index 741a25ab90d..4c616d15d98 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -41,6 +41,33 @@ HERE = Path(__file__).resolve().parent +def check_server(ip="127.0.0.1", port=22): + """Check if a server is listening on the given IP and port. + + Args + ---- + ip (str, optional): IP address to check. Defaults to "127.0.0.1". + port (int, optional): Port to check. Defaults to 22. + + Returns + ------- + bool: server is listening on the given IP and port + """ + test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + test_socket.connect((ip, port)) + except OSError: + return False + else: + test_socket.close() + return True + + +def await_future(future: Future[T], timeout: float = 30) -> T: + """Wait for a future to finish or timeout.""" + return future.result(timeout=timeout) + + class MainWindowMock(QMainWindow): """QMainWindow mock for the Remote Client plugin tests.""" @@ -120,7 +147,8 @@ def remote_client_id( def remote_client( ipyconsole_and_remoteclient: typing.Tuple[IPythonConsole, RemoteClient], ) -> RemoteClient: - """Start the Spyder Remote Client plugin. + """ + Start the Spyder Remote Client plugin. Yields ------ @@ -133,7 +161,8 @@ def remote_client( def ipyconsole( ipyconsole_and_remoteclient: typing.Tuple[IPythonConsole, RemoteClient], ) -> IPythonConsole: - """Start the IPython Console plugin. + """ + Start the IPython Console plugin. Yields ------ @@ -206,30 +235,3 @@ def ssh_server_addr( ) return docker_ip, port - - -def check_server(ip="127.0.0.1", port=22): - """Check if a server is listening on the given IP and port. - - Args - ---- - ip (str, optional): IP address to check. Defaults to "127.0.0.1". - port (int, optional): Port to check. Defaults to 22. - - Returns - ------- - bool: server is listening on the given IP and port - """ - test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - test_socket.connect((ip, port)) - except OSError: - return False - else: - test_socket.close() - return True - - -def await_future(future: Future[T], timeout: float = 30) -> T: - """Wait for a future to finish or timeout.""" - return future.result(timeout=timeout) From 3ed4d96e33d1ebf5266f3e2de4293406b84a3f3b Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Oct 2024 12:14:13 -0300 Subject: [PATCH 26/29] feat: add new shell fixture and set class fixture for kernels --- runtests.py | 2 +- spyder/plugins/remoteclient/tests/conftest.py | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/runtests.py b/runtests.py index c1fb894975e..000e3f449b9 100644 --- a/runtests.py +++ b/runtests.py @@ -50,7 +50,7 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): pytest_args += extra_args if remoteclient: - pytest_args += ['./spyder/plugins/remoteclient'] + pytest_args += ['--container-scope=class' ,'./spyder/plugins/remoteclient'] os.environ["SPYDER_TEST_REMOTE_CLIENT"] = "true" else: pytest_args += ['--ignore=./spyder/plugins/remoteclient'] diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index 4c616d15d98..9168b89db4e 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -105,10 +105,26 @@ def docker_compose_file(pytestconfig): return str(HERE / "docker-compose.yml") -@pytest.fixture(scope="session") +@pytest.fixture() +def shell(ipyconsole, remote_client, remote_client_id, qtbot): + """Create a new shell widget.""" + remote_client.create_ipyclient_for_server(remote_client_id) + client = ipyconsole.get_current_client() + shell = client.shellwidget + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=180000, + ) + + yield shell + + ipyconsole.get_widget().close_client(client=client) + + +@pytest.fixture(scope="class") def remote_client_id( ssh_server_addr: typing.Tuple[str, int], remote_client: RemoteClient -) -> typing.Iterator[SpyderRemoteClient]: +) -> typing.Iterator[str]: """Add the Spyder Remote Client plugin to the registry.""" ssh_options = { "host": ssh_server_addr[0], @@ -172,7 +188,7 @@ def ipyconsole( @pytest.fixture(scope="session") -def ipyconsole_and_remoteclient() -> ( +def ipyconsole_and_remoteclient(qapp) -> ( typing.Iterator[typing.Tuple[IPythonConsole, RemoteClient]] ): """Start the Spyder Remote Client plugin with IPython Console. @@ -181,8 +197,6 @@ def ipyconsole_and_remoteclient() -> ( ------ tuple: IPython Console and Spyder Remote Client plugins """ - app = qapplication(translate=False) - app.setQuitOnLastWindowClosed(False) window = MainWindowMock() @@ -200,16 +214,11 @@ def ipyconsole_and_remoteclient() -> ( os.environ.pop("IPYCONSOLE_TESTING") CONF.reset_manager() PLUGIN_REGISTRY.reset() - if app.instance(): - for widget in app.allWidgets(): - with contextlib.suppress(RuntimeError): - widget.close() - app.quit() del window gc.collect() -@pytest.fixture(scope="session") +@pytest.fixture(scope="class") def ssh_server_addr( docker_ip: str, docker_services: Services ) -> typing.Tuple[str, int]: From 218bee994a37fc1f3d1116cd254359a54273d4c4 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Oct 2024 12:15:10 -0300 Subject: [PATCH 27/29] feat: add test class and test for kernel interrupts --- .../remoteclient/tests/test_ipythonconsole.py | 179 +++++++++--------- 1 file changed, 92 insertions(+), 87 deletions(-) diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py index f5f17c393d2..c4a8441446f 100644 --- a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -15,93 +15,98 @@ from spyder.plugins.remoteclient.tests.conftest import await_future -@flaky(max_runs=3, min_passes=1) -def test_shutdown_kernel(ipyconsole, remote_client, remote_client_id, qtbot): - """Starts and stops a kernel on the remote server.""" - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, # longer timeout for installation - ) - - ipyconsole.get_widget().close_client() - - assert ( - await_future(remote_client.get_kernels(remote_client_id), timeout=10) - == [] - ) - - -def test_restart_kernel(ipyconsole, remote_client, remote_client_id, qtbot): - """Test that kernel is restarted correctly.""" - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, - ) - - # Do an assignment to verify that it's not there after restarting - with qtbot.waitSignal(shell.executed): - shell.execute("a = 10") - - # Restart kernel and wait until it's up again. - # NOTE: We trigger the restart_action instead of calling `restart_kernel` - # directly to also check that that action is working as expected and avoid - # regressions such as spyder-ide/spyder#22084. - shell._prompt_html = None - ipyconsole.get_widget().restart_action.trigger() - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=4000, - ) - - assert not shell.is_defined("a") - - with qtbot.waitSignal(shell.executed): - shell.execute("b = 10") - - # Make sure that kernel is responsive after restart - qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) - - ipyconsole.get_widget().close_client() - - -def test_kernel_kill(ipyconsole, remote_client, remote_client_id, qtbot): - """Test that the kernel correctly restarts after a kill.""" - crash_string = "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" - - remote_client.create_ipyclient_for_server(remote_client_id) - shell = ipyconsole.get_current_shellwidget() - qtbot.waitUntil( - lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=180000, - ) - - # Since the heartbeat and the tunnels are running in separate threads, we - # need to make sure that the heartbeat thread has "higher" priority than - # the tunnel thread, otherwise the kernel will be restarted and the tunnels - # recreated before the heartbeat can detect the kernel is dead. - # In the test enviroment, the heartbeat needs to be set to a lower value - # because there are fewer threads running. - shell.kernel_handler.set_time_to_dead(0.2) - - shell._prompt_html = None - with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): - shell.execute(crash_string) - - assert "The kernel died, restarting..." in shell._control.toPlainText() - - with qtbot.waitSignal(shell.executed): - shell.execute("b = 10") - - # Make sure that kernel is responsive after restart - qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) - - ipyconsole.get_widget().close_client() +class TestIpythonConsole: + @flaky(max_runs=3, min_passes=1) + def test_shutdown_kernel( + self, ipyconsole, remote_client, remote_client_id, qtbot + ): + """Starts and stops a kernel on the remote server.""" + remote_client.create_ipyclient_for_server(remote_client_id) + shell = ipyconsole.get_current_shellwidget() + + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready + and shell._prompt_html is not None, + timeout=180000, # longer timeout for installation + ) + + ipyconsole.get_widget().close_client() + + assert ( + await_future( + remote_client.get_kernels(remote_client_id), timeout=10 + ) + == [] + ) + + def test_restart_kernel(self, shell, ipyconsole, qtbot): + """Test that kernel is restarted correctly.""" + # Do an assignment to verify that it's not there after restarting + with qtbot.waitSignal(shell.executed): + shell.execute("a = 10") + + # Restart kernel and wait until it's up again. + # NOTE: We trigger the restart_action instead of calling `restart_kernel` + # directly to also check that that action is working as expected and avoid + # regressions such as spyder-ide/spyder#22084. + shell._prompt_html = None + ipyconsole.get_widget().restart_action.trigger() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready + and shell._prompt_html is not None, + timeout=4000, + ) + + assert not shell.is_defined("a") + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) + + def test_interrupt_kernel(self, shell, qtbot): + """Test that the kernel correctly interrupts.""" + loop_string = "while True: pass" + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + shell.execute(loop_string) + + qtbot.wait(500) + + shell.interrupt_kernel() + + qtbot.wait(1000) + + # Make sure that kernel didn't die + assert shell.get_value("b") == 10 + + def test_kernel_kill(self, shell, qtbot): + """Test that the kernel correctly restarts after a kill.""" + crash_string = ( + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + ) + + # Since the heartbeat and the tunnels are running in separate threads, we + # need to make sure that the heartbeat thread has "higher" priority than + # the tunnel thread, otherwise the kernel will be restarted and the tunnels + # recreated before the heartbeat can detect the kernel is dead. + # In the test enviroment, the heartbeat needs to be set to a lower value + # because there are fewer threads running. + shell.kernel_handler.set_time_to_dead(0.2) + + with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000): + shell.execute(crash_string) + + assert "The kernel died, restarting..." in shell._control.toPlainText() + + with qtbot.waitSignal(shell.executed): + shell.execute("b = 10") + + # Make sure that kernel is responsive after restart + qtbot.waitUntil(lambda: shell.is_defined("b"), timeout=2000) if __name__ == "__main__": From 525a049e464d7921717f4fbf3583c30bd76764a6 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Sun, 6 Oct 2024 12:20:22 -0300 Subject: [PATCH 28/29] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- spyder/plugins/remoteclient/tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index 9168b89db4e..b61ff1431b1 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -154,7 +154,6 @@ def remote_client_id( try: remote_client.load_client(options=ssh_options, config_id=config_id) yield config_id - finally: remote_client.on_close(cancellable=False) @@ -191,7 +190,8 @@ def ipyconsole( def ipyconsole_and_remoteclient(qapp) -> ( typing.Iterator[typing.Tuple[IPythonConsole, RemoteClient]] ): - """Start the Spyder Remote Client plugin with IPython Console. + """ + Start the Spyder Remote Client plugin with IPython Console. Yields ------ From 31441a0070c2822adbd57f06f991bb1b8916a7d9 Mon Sep 17 00:00:00 2001 From: Hendrik Dumith Louzada Date: Sun, 6 Oct 2024 12:27:33 -0300 Subject: [PATCH 29/29] fix: pep8 --- runtests.py | 6 ++++-- spyder/plugins/remoteclient/tests/conftest.py | 3 --- .../remoteclient/tests/test_ipythonconsole.py | 18 +++++++----------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/runtests.py b/runtests.py index 000e3f449b9..8a45232452d 100644 --- a/runtests.py +++ b/runtests.py @@ -50,7 +50,8 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): pytest_args += extra_args if remoteclient: - pytest_args += ['--container-scope=class' ,'./spyder/plugins/remoteclient'] + pytest_args += ['--container-scope=class', + './spyder/plugins/remoteclient'] os.environ["SPYDER_TEST_REMOTE_CLIENT"] = "true" else: pytest_args += ['--ignore=./spyder/plugins/remoteclient'] @@ -68,7 +69,8 @@ def run_pytest(run_slow=False, extra_args=None, remoteclient=False): def main(): """Parse args then run the pytest suite for Spyder.""" test_parser = argparse.ArgumentParser( - usage='python runtests.py [-h] [--run-slow] [--remote-client] [pytest_args]', + usage=('python runtests.py' + '[-h] [--run-slow] [--remote-client] [pytest_args]'), description="Helper script to run Spyder's test suite") test_parser.add_argument('--run-slow', action='store_true', default=False, help='Run the slow tests') diff --git a/spyder/plugins/remoteclient/tests/conftest.py b/spyder/plugins/remoteclient/tests/conftest.py index b61ff1431b1..cdf69208b1d 100644 --- a/spyder/plugins/remoteclient/tests/conftest.py +++ b/spyder/plugins/remoteclient/tests/conftest.py @@ -9,7 +9,6 @@ from __future__ import annotations from concurrent.futures import Future -import contextlib import gc import os from pathlib import Path @@ -26,10 +25,8 @@ from spyder.app.cli_options import get_options from spyder.config.manager import CONF from spyder.plugins.ipythonconsole.plugin import IPythonConsole -from spyder.plugins.remoteclient.api.client import SpyderRemoteClient from spyder.plugins.remoteclient.plugin import RemoteClient from spyder.plugins.remoteclient.widgets import AuthenticationMethod -from spyder.utils.qthelpers import qapplication T = typing.TypeVar("T") diff --git a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py index c4a8441446f..9cd6263565c 100644 --- a/spyder/plugins/remoteclient/tests/test_ipythonconsole.py +++ b/spyder/plugins/remoteclient/tests/test_ipythonconsole.py @@ -8,8 +8,8 @@ """IPython console and Spyder Remote Client integration tests.""" # Third party imports -import pytest from flaky import flaky +import pytest # Local imports from spyder.plugins.remoteclient.tests.conftest import await_future @@ -45,10 +45,6 @@ def test_restart_kernel(self, shell, ipyconsole, qtbot): with qtbot.waitSignal(shell.executed): shell.execute("a = 10") - # Restart kernel and wait until it's up again. - # NOTE: We trigger the restart_action instead of calling `restart_kernel` - # directly to also check that that action is working as expected and avoid - # regressions such as spyder-ide/spyder#22084. shell._prompt_html = None ipyconsole.get_widget().restart_action.trigger() qtbot.waitUntil( @@ -89,12 +85,12 @@ def test_kernel_kill(self, shell, qtbot): "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" ) - # Since the heartbeat and the tunnels are running in separate threads, we - # need to make sure that the heartbeat thread has "higher" priority than - # the tunnel thread, otherwise the kernel will be restarted and the tunnels - # recreated before the heartbeat can detect the kernel is dead. - # In the test enviroment, the heartbeat needs to be set to a lower value - # because there are fewer threads running. + # Since the heartbeat and the tunnels are running in separate threads, + # we need to make sure that the heartbeat thread has "higher" priority + # than the tunnel thread, otherwise the kernel will be restarted and + # the tunnels recreated before the heartbeat can detect the kernel + # is dead. In the test enviroment, the heartbeat needs to be set to a + # lower value because there are fewer threads running. shell.kernel_handler.set_time_to_dead(0.2) with qtbot.waitSignal(shell.sig_prompt_ready, timeout=30000):