diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 42dce26b..52268702 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,4 +1,4 @@ -name: Tests on CI +name: Tests on: pull_request: diff --git a/src/serena/cli.py b/src/serena/cli.py index 3cc66d14..711e8e9a 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -638,7 +638,7 @@ class ProjectCommands(AutoRegisteringGroup): lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() - proj = registered_project.get_project_instance() + proj = registered_project.get_project_instance(serena_config=serena_config) click.echo(f"Indexing symbols in {proj} …") ls_mgr = proj.create_language_server_manager( log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings @@ -691,7 +691,8 @@ class ProjectCommands(AutoRegisteringGroup): :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) @@ -713,7 +714,8 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): @@ -750,7 +752,8 @@ class ProjectCommands(AutoRegisteringGroup): # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation logging.configure(level=logging.INFO) project_path = os.path.abspath(project) - proj = Project.load(project_path) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp timestamp = datetime_tag() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5f6b3fe6..6599a591 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -397,12 +397,18 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi class RegisteredProject(ToStringMixin): - def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None: + def __init__( + self, + project_root: str, + project_config: "ProjectConfig", + project_instance: Optional["Project"] = None, + ) -> None: """ Represents a registered project in the Serena configuration. :param project_root: the root directory of the project :param project_config: the configuration of the project + :param project_instance: an existing project instance (if already loaded) """ self.project_root = Path(project_root).resolve() self.project_config = project_config @@ -440,7 +446,7 @@ class RegisteredProject(ToStringMixin): """ return self.project_root.samefile(Path(path).resolve()) - def get_project_instance(self) -> "Project": + def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project": """ Returns the project instance for this registered project, loading it if necessary. """ @@ -448,7 +454,11 @@ class RegisteredProject(ToStringMixin): from ..project import Project with LogTime(f"Loading project instance for {self}", logger=log): - self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config) + self._project_instance = Project( + project_root=str(self.project_root), + project_config=self.project_config, + serena_config=serena_config, + ) return self._project_instance @@ -493,6 +503,10 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" + ignored_paths: list[str] = field(default_factory=list) + """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. + These patterns are merged additively with each project's own ignored_paths.""" + # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") @@ -724,7 +738,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if registered_project is None: return None else: - return registered_project.get_project_instance() + return registered_project.get_project_instance(serena_config=self) def add_registered_project(self, registered_project: RegisteredProject) -> None: """ @@ -758,7 +772,12 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project_config = ProjectConfig.load(project_root, autogenerate=True) - new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) + new_project = Project( + project_root=str(project_root), + project_config=project_config, + is_newly_created=True, + serena_config=self, + ) self.add_registered_project(RegisteredProject.from_project_instance(new_project)) return new_project diff --git a/src/serena/project.py b/src/serena/project.py index ea3674ad..c176dddb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -3,7 +3,7 @@ import logging import os import threading from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pathspec from sensai.util.logging import LogTime @@ -18,6 +18,9 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils +if TYPE_CHECKING: + from serena.config.serena_config import SerenaConfig + log = logging.getLogger(__name__) @@ -56,7 +59,13 @@ class MemoriesManager: class Project(ToStringMixin): - def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False): + def __init__( + self, + project_root: str, + project_config: ProjectConfig, + is_newly_created: bool = False, + serena_config: "SerenaConfig | None" = None, + ): self.project_root = project_root self.project_config = project_config self.memories_manager = MemoriesManager(project_root) @@ -72,6 +81,7 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. + self._serena_config = serena_config self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -80,11 +90,16 @@ class Project(ToStringMixin): def _gather_ignorespec(self) -> None: with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): - # gather ignored paths from the project configuration and gitignore files - ignored_patterns = list(self.project_config.ignored_paths) - if len(ignored_patterns) > 0: - log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.") - log.debug(f"Ignored paths: {ignored_patterns}") + # gather ignored paths from the global configuration, project configuration, and gitignore files + global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else [] + ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths) + if len(global_ignored_paths) > 0: + log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.") + log.debug(f"Global ignored paths: {list(global_ignored_paths)}") + if len(self.project_config.ignored_paths) > 0: + log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") + log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") + log.debug(f"Combined ignored patterns: {ignored_patterns}") if self.project_config.ignore_all_files_in_gitignore: gitignore_parser = GitignoreParser(self.project_root) for spec in gitignore_parser.get_ignore_specs(): @@ -115,12 +130,17 @@ class Project(ToStringMixin): return self.project_config.project_name @classmethod - def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project": + def load( + cls, + project_root: str | Path, + serena_config: "SerenaConfig | None", + autogenerate: bool = True, + ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return Project(project_root=str(project_root), project_config=project_config) + return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: """ diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 1ce23a99..8276989d 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -34,8 +34,9 @@ encoding: "utf-8" # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore in all projects -# same syntax as gitignore, so you can use * and ** +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 7a660e64..ac11088e 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -63,6 +63,11 @@ trace_lsp_communication: False # No documentation on options means no options are available. ls_specific_settings: {} +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + # timeout, in seconds, after which tool executions are terminated tool_timeout: 240 diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index afd8d579..67f7ac28 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -118,7 +118,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark response_dict = client.find_references( name_path=name_path, relative_path=relative_path, - include_quick_info=include_info, + include_quick_info=False, # TODO: Hotfix for serena-jetbrains-plugin/issues/13; revert once fixed ) symbol_dicts = response_dict["symbols"] result = self.symbol_dict_grouper.group(symbol_dicts) diff --git a/test/conftest.py b/test/conftest.py index 6d2cf211..565d40b7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import logging import os +import platform import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager @@ -116,7 +117,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def _create_default_project(language: Language) -> Project: repo_path = str(get_repo_path(language)) - return Project.load(repo_path) + return Project.load(repo_path, serena_config=None) @pytest.fixture(scope="session") @@ -209,6 +210,8 @@ is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" Flag indicating whether the tests are running in the GitHub CI environment. """ +is_windows = platform.system() == "Windows" + def _determine_disabled_languages() -> list[Language]: """ @@ -236,6 +239,11 @@ def _determine_disabled_languages() -> list[Language]: if not clangd_tests_enabled: result.append(Language.CPP) + # Disable PHP_PHPACTOR tests if php is not available + php_tests_enabled = _sh.which("php") is not None + if not php_tests_enabled: + result.append(Language.PHP_PHPACTOR) + al_tests_enabled = True if not al_tests_enabled: result.append(Language.AL) diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py new file mode 100644 index 00000000..23481373 --- /dev/null +++ b/test/serena/config/test_global_ignored_paths.py @@ -0,0 +1,261 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _create_test_project( + project_root: Path, + project_ignored_paths: list[str] | None = None, + global_ignored_paths: list[str] | None = None, +) -> Project: + """Helper to create a Project with the given ignored paths configuration.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=project_ignored_paths or [], + ignore_all_files_in_gitignore=False, + ) + serena_config: SerenaConfig | None = None + if global_ignored_paths: + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) + return Project( + project_root=str(project_root), + project_config=config, + serena_config=serena_config, + ) + + +class TestGlobalIgnoredPaths: + """Tests for system-global ignored_paths feature.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) + # Create some test files and directories + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True) + (self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + os.makedirs(self.project_path / "src", exist_ok=True) + (self.project_path / "src" / "app.py").write_text("def app(): pass") + (self.project_path / "debug.log").write_text("log data") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_global_ignored_paths_are_applied(self) -> None: + """Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path().""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["node_modules"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_additive_merge_of_global_and_project_patterns(self) -> None: + """Global + project patterns are merged additively (both applied).""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=["node_modules"], + ) + # Global pattern should be applied + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + # Project pattern should also be applied + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files should not be affected + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_empty_global_ignored_paths_has_no_effect(self) -> None: + """Empty global ignored_paths (default) has no effect on existing behavior.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=[], + ) + # Project pattern still works + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files still accessible + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_default_global_ignored_paths_backward_compatible(self) -> None: + """Project created without global_ignored_paths parameter works as before.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=False, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + ) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_duplicate_patterns_across_global_and_project(self) -> None: + """Duplicate patterns across global and project do not cause errors.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["node_modules", "build"], + global_ignored_paths=["node_modules", "build"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_glob_patterns_in_global_ignored_paths(self) -> None: + """Global ignored_paths support gitignore-style glob patterns.""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + assert project.is_ignored_path(str(self.project_path / "debug.log")) + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestRegisteredProjectGlobalIgnoredPaths: + """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_get_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.get_project_instance() passes global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance(serena_config=serena_config) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_get_project_instance_without_global_ignored_paths(self) -> None: + """RegisteredProject without global_ignored_paths defaults to empty.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance(serena_config=None) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_root_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_root() threads global_ignored_paths to Project.""" + # Create a minimal project.yml so from_project_root can load config + serena_dir = self.project_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + registered = RegisteredProject.from_project_root( + str(self.project_path), + ) + project = registered.get_project_instance(serena_config=serena_config) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_instance() threads global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + project = Project( + project_root=str(self.project_path), + project_config=config, + serena_config=serena_config, + ) + registered = RegisteredProject.from_project_instance(project) + # The registered project already has a project_instance, so get_project_instance() returns it directly + retrieved = registered.get_project_instance(serena_config=serena_config) + assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + +class TestGlobalIgnoredPathsWithGitignore: + """Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + # Create test files + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + os.makedirs(self.project_path / "dist", exist_ok=True) + (self.project_path / "dist" / "bundle.js").write_text("bundled") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + # Create .gitignore that ignores dist/ + (self.project_path / ".gitignore").write_text("dist/\n") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_three_way_merge_global_project_and_gitignore(self) -> None: + """Global patterns, project patterns, and .gitignore patterns are all applied together.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=True, + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + project = Project( + project_root=str(self.project_path), + project_config=config, + serena_config=serena_config, + ) + # Global pattern: node_modules + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + # Project pattern: build + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Gitignore pattern: dist/ + assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js")) + # Non-ignored file + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestSerenaConfigIgnoredPaths: + """Config loading with ignored_paths in serena_config.yml works correctly.""" + + def test_serena_config_default_ignored_paths(self) -> None: + """SerenaConfig defaults to empty ignored_paths.""" + config = SerenaConfig(gui_log_window=False, web_dashboard=False) + assert config.ignored_paths == [] + + def test_serena_config_with_ignored_paths(self) -> None: + """SerenaConfig can be created with explicit ignored_paths.""" + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + ignored_paths=["node_modules", "*.log", "build"], + ) + assert config.ignored_paths == ["node_modules", "*.log", "build"] diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index b43be9e8..abd6bc2c 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -15,7 +15,7 @@ from serena.project import Project from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import get_repo_path, language_tests_enabled +from test.conftest import get_repo_path, is_ci, language_tests_enabled from test.solidlsp import clojure as clj @@ -124,6 +124,12 @@ class TestSerenaAgent: indirect=["serena_agent"], ) def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): + # skip flaky tests in CI + # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 + flaky_languages = {Language.FSHARP} + if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: + pytest.skip("Test is flaky and thus skipped in CI environment.") + agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex(name_path_pattern=symbol_name, include_info=True) @@ -193,6 +199,12 @@ class TestSerenaAgent: indirect=["serena_agent"], ) def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + # skip flaky tests in CI + # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1039 + flaky_languages = {Language.TYPESCRIPT} + if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: + pytest.skip("Test is flaky and thus skipped in CI environment.") + agent = serena_agent # Find the symbol location first diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 15de4ad7..4e4e03c7 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -10,6 +10,7 @@ from solidlsp import SolidLanguageServer from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci @pytest.mark.fsharp @@ -87,6 +88,7 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} not found in Person.fs symbols" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1039 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to Calculator functions across files.""" diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 5c4e3293..9bfedbf3 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @@ -115,6 +116,7 @@ class TestNixLanguageServer: # Check if we found the inherit (line 67, 0-indexed: 66) assert 66 in ref_lines, f"Should find makeGreeting inherit at line 67, found at lines {[l+1 for l in ref_lines]}" + @pytest.mark.skipif(is_ci, reason="Test is flaky") # TODO: Re-enable if the hover test becomes more stable (#1039) @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information for symbols.""" diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index 5396687d..d10f1468 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -1,22 +1,29 @@ -import platform from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci, is_windows, language_tests_enabled + +_php_servers: list[Language] = [Language.PHP] +if language_tests_enabled(Language.PHP_PHPACTOR): + _php_servers.append(Language.PHP_PHPACTOR) +@pytest.mark.skipif( + is_ci and is_windows, reason="Tests are flaky" +) # TODO: Re-enable once we have a solution for running Phpactor tests on Windows CI #1039 @pytest.mark.php class TestPhpLanguageServers: - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: """Test that the language server starts and stops successfully.""" assert language_server.is_running() assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: @@ -46,7 +53,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) @@ -66,7 +73,7 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: file_path = str(repo_path / "simple_var.php") @@ -91,12 +98,9 @@ class TestPhpLanguageServers: if language_server.language_server.language != Language.PHP_PHPACTOR: assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - if language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": - pytest.skip("Phpactor references test not working on Windows, skipping for now") - index_php_path = str(repo_path / "index.php") # In index.php (0-indexed lines): @@ -142,12 +146,9 @@ class TestPhpLanguageServers: assert actual_locations == expected_locations - @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: - if language_server.language_server.language == Language.PHP_PHPACTOR and platform.system() == "Windows": - pytest.skip("Phpactor references test not working on Windows, skipping for now") - helper_php_path = str(repo_path / "helper.php") # In index.php (0-indexed lines): # Line 13: helperFunction(); // Usage of helperFunction diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index f98fd05d..d43cf836 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -1,17 +1,18 @@ import os -import platform -import time import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import is_ci +from test.conftest import is_ci, is_windows class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" + @pytest.mark.skipif( + is_ci and is_windows, reason="This test is flaky in Windows CI (file system does not update modified time reliably)." + ) @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ @@ -32,11 +33,6 @@ class TestLanguageServerCommonFunctionality: with open(file_path, "w") as f: f.write(test_string2) - # ensure that mtime is updated for Windows CI (odd FS behaviour, updates lazily) - if is_ci and platform.system() == "Windows": - now = time.time() - os.utime(file_path, (now, now)) - # check that the file buffer has been invalidated and reloaded assert fb.contents == test_string2