diff --git a/AGENTS.md b/AGENTS.md index 52f67d0..9ffd4bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,7 @@ pypeline run -i param=value # Pass input parameters - Testability: pure functions where possible; pass dependencies, avoid globals/singletons. - tests: use `pytest`; keep the tests to a minimum; use parametrized tests when possible; do no add useless comments; the tests shall be self-explanatory. - pytest fixtures: use them to avoid code duplication; use `conftest.py` for shared fixtures. Use `tmp_path` in case of file system operations. + - Fixtures are for **incidental** setup only (e.g. `tmp_path`, mocked external boundaries) — things you don't care about while reading the assertion. Do **NOT** hide the construction of the **system under test** in a fixture: building the object being tested (and its direct collaborators/config) MUST stay inline in each test, so the test reads top-to-bottom and the subject is visible without hunting up the file. Self-explanatory tests (above) win over de-duplicating one-line SUT construction. ## Code Quality Rules diff --git a/src/pypeline/steps/scoop_install.py b/src/pypeline/steps/scoop_install.py index c13dc29..288ab38 100644 --- a/src/pypeline/steps/scoop_install.py +++ b/src/pypeline/steps/scoop_install.py @@ -1,102 +1,195 @@ -import io -import json -import platform -import traceback -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, ClassVar, Dict, List, Optional - -from mashumaro.config import TO_DICT_ADD_OMIT_NONE_FLAG, BaseConfig -from mashumaro.mixins.json import DataClassJSONMixin -from py_app_dev.core.exceptions import UserNotificationException -from py_app_dev.core.logging import logger -from py_app_dev.core.scoop_wrapper import ScoopWrapper - -from ..domain.execution_context import ExecutionContext -from ..domain.pipeline import PipelineStep -from ..main import package_version_file - - -@dataclass -class ScoopInstallExecutionInfo(DataClassJSONMixin): - install_dirs: List[Path] - env_vars: Dict[str, Any] = field(default_factory=dict) - - class Config(BaseConfig): - """Base configuration for JSON serialization with omitted None values.""" - - code_generation_options: ClassVar[List[str]] = [TO_DICT_ADD_OMIT_NONE_FLAG] - - @classmethod - def from_json_file(cls, file_path: Path) -> "ScoopInstallExecutionInfo": - try: - result = cls.from_dict(json.loads(file_path.read_text())) - except Exception as e: - output = io.StringIO() - traceback.print_exc(file=output) - raise UserNotificationException(output.getvalue()) from e - return result - - def to_json_string(self) -> str: - return json.dumps(self.to_dict(omit_none=True), indent=2) - - def to_json_file(self, file_path: Path) -> None: - file_path.write_text(self.to_json_string()) - - -def create_scoop_wrapper() -> ScoopWrapper: - return ScoopWrapper() - - -class ScoopInstall(PipelineStep[ExecutionContext]): - def __init__(self, execution_context: ExecutionContext, group_name: str, config: Optional[Dict[str, Any]] = None) -> None: - super().__init__(execution_context, group_name, config) - self.logger = logger.bind() - self.execution_info = ScoopInstallExecutionInfo([]) - # One needs to keep track of the installed apps to get the required paths - # even if the step does not need to run. - self.execution_info_file = self.output_dir.joinpath("scoop_install_exec_info.json") - - def get_name(self) -> str: - return self.__class__.__name__ - - @property - def install_dirs(self) -> List[Path]: - return self.execution_info.install_dirs - - @property - def scoop_file(self) -> Path: - return self.project_root_dir.joinpath("scoopfile.json") - - def run(self) -> int: - self.logger.debug(f"Run {self.get_name()} step. Output dir: {self.output_dir}") - - if platform.system() != "Windows": - self.logger.warning(f"ScoopInstall skipped on non-Windows platform ({platform.system()}).") - self.execution_info.to_json_file(self.execution_info_file) - return 0 - - installed_apps = create_scoop_wrapper().install(self.scoop_file) - self.logger.debug("Installed apps:") - for app in installed_apps: - self.logger.debug(f" - {app.name} ({app.version})") - self.install_dirs.extend(app.get_all_required_paths()) - # Collect environment variables from each app - self.execution_info.env_vars.update(app.env_vars) - self.execution_info.to_json_file(self.execution_info_file) - return 0 - - def get_inputs(self) -> List[Path]: - return [self.scoop_file, package_version_file()] - - def get_outputs(self) -> List[Path]: - return [self.execution_info_file, *self.install_dirs] - - def update_execution_context(self) -> None: - execution_info = ScoopInstallExecutionInfo.from_json_file(self.execution_info_file) - # Make the list unique and keep the order - unique_paths = list(dict.fromkeys(execution_info.install_dirs)) - # Update the install directories for the subsequent steps - self.execution_context.add_install_dirs(unique_paths) - if execution_info.env_vars: - self.execution_context.add_env_vars(execution_info.env_vars) +import json +import platform +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Generic, TypeVar + +from py_app_dev.core.config import BaseConfigJSONMixin +from py_app_dev.core.exceptions import UserNotificationException +from py_app_dev.core.logging import logger +from py_app_dev.core.scoop_wrapper import ScoopFileElement, ScoopWrapper + +from ..domain.execution_context import ExecutionContext +from ..domain.pipeline import PipelineStep +from ..main import package_version_file + + +@dataclass +class ScoopManifest(BaseConfigJSONMixin): + #: Scoop buckets + buckets: list[ScoopFileElement] = field(default_factory=list) + #: Scoop applications + apps: list[ScoopFileElement] = field(default_factory=list) + # This field is intended to keep track of where configuration was loaded from and + # it is automatically added when configuration is loaded from file + file: Path | None = None + + @classmethod + def from_file(cls, config_file: Path) -> "ScoopManifest": + config_dict = cls.parse_to_dict(config_file) + return cls.from_dict(config_dict) + + @staticmethod + def parse_to_dict(config_file: Path) -> dict[str, Any]: + try: + with open(config_file) as fs: + config_dict = json.loads(fs.read()) + config_dict["file"] = config_file + return config_dict + except json.JSONDecodeError as e: + raise UserNotificationException(f"Failed parsing scoop manifest file '{config_file}'. \nError: {e}") from e + + +@dataclass +class ScoopInstallExecutionInfo(BaseConfigJSONMixin): + #: Directories that are added to PATH for subsequent steps (bin dirs + env_add_path). + install_dirs: list[Path] = field(default_factory=list) + #: Root directory of every installed app. Tracked only to detect out-of-band uninstalls + #: (an app with no bin/env_add_path would otherwise leave nothing to check). NOT added to PATH. + dependency_dirs: list[Path] = field(default_factory=list) + env_vars: dict[str, Any] = field(default_factory=dict) + + def to_json_file(self, file_path: Path) -> None: + file_path.parent.mkdir(parents=True, exist_ok=True) + super().to_json_file(file_path) + + +def create_scoop_wrapper() -> ScoopWrapper: + return ScoopWrapper() + + +TContext = TypeVar("TContext", bound=ExecutionContext) + + +class ScoopInstall(PipelineStep[TContext], Generic[TContext]): + def __init__(self, execution_context: TContext, group_name: str, config: dict[str, Any] | None = None) -> None: + super().__init__(execution_context, group_name, config) + self.logger = logger.bind() + self.execution_info = ScoopInstallExecutionInfo() + + def get_name(self) -> str: + return self.__class__.__name__ + + @property + def install_dirs(self) -> list[Path]: + return self.execution_info.install_dirs + + @property + def _execution_info_file(self) -> Path: + """Tracks execution info (installed dirs, env vars).""" + return self.output_dir / "scoop_install_exec_info.json" + + @property + def _output_manifest_file(self) -> Path: + """Generated scoopfile.json (output).""" + return self.output_dir / "scoopfile.json" + + @property + def _source_manifest_file(self) -> Path: + """Source scoopfile.json. Override to customize.""" + return self.project_root_dir / "scoopfile.json" + + def _collect_dependencies(self) -> ScoopManifest: + """Collect Scoop dependencies. Override to add additional sources.""" + collected_manifest = ScoopManifest() + + if self._source_manifest_file.exists(): + source_manifest = ScoopManifest.from_file(self._source_manifest_file) + self._merge_buckets(collected_manifest, source_manifest.buckets) + self._merge_apps(collected_manifest, source_manifest.apps) + + return collected_manifest + + def _merge_buckets(self, target_manifest: ScoopManifest, source_buckets: list[ScoopFileElement]) -> None: + """Merge buckets, handling conflicts when same name has different sources.""" + for bucket in source_buckets: + existing_bucket = next((b for b in target_manifest.buckets if b.name == bucket.name), None) + + if existing_bucket is None: + target_manifest.buckets.append(bucket) + elif existing_bucket.source != bucket.source: + self.logger.warning( + f"Bucket '{bucket.name}' defined multiple times with different sources:\n" + f" Existing: {existing_bucket.source}\n" + f" New: {bucket.source}\n" + f" Keeping existing definition." + ) + + def _merge_apps(self, target_manifest: ScoopManifest, source_apps: list[ScoopFileElement]) -> None: + """Merge apps, warning when the same app is declared with a different source or version.""" + for app in source_apps: + existing_app = next((a for a in target_manifest.apps if a.name == app.name), None) + + if existing_app is None: + target_manifest.apps.append(app) + elif existing_app != app: + self.logger.warning( + f"App '{app.name}' defined multiple times with different definitions:\n" + f" Existing: source={existing_app.source}, version={existing_app.version}\n" + f" New: source={app.source}, version={app.version}\n" + f" Keeping existing definition." + ) + + def _generate_scoop_manifest(self, manifest: ScoopManifest) -> None: + """Generate scoopfile.json file from collected dependencies.""" + if not manifest.buckets and not manifest.apps: + self.logger.info("No Scoop dependencies found. Skipping scoopfile.json generation.") + return + + self._output_manifest_file.parent.mkdir(parents=True, exist_ok=True) + self._output_manifest_file.write_text(manifest.to_json_string()) + + self.logger.info(f"Generated scoopfile.json with {len(manifest.buckets)} buckets and {len(manifest.apps)} apps") + + def run(self) -> int: + self.logger.debug(f"Run {self.get_name()} step. Output dir: {self.output_dir}") + + if platform.system() != "Windows": + self.logger.warning(f"ScoopInstall step is only supported on Windows. Skipping. Current platform: {platform.system()}") + return 0 + + collected_manifest = self._collect_dependencies() + self._generate_scoop_manifest(collected_manifest) + + if not collected_manifest.apps: + self.logger.info("No Scoop dependencies to install.") + return 0 + + try: + installed_apps = create_scoop_wrapper().install(self._output_manifest_file) + except Exception as e: + raise UserNotificationException(f"Failed to install scoop dependencies: {e}") from e + + self.logger.debug("Installed apps:") + for app in installed_apps: + self.logger.debug(f" - {app.name} ({app.version})") + self.execution_info.install_dirs.extend(app.get_all_required_paths()) + # Track the app root so an out-of-band `scoop uninstall` is detected on the next run, + # even when the app contributes no PATH directories (e.g. an env-var-only tool). + self.execution_info.dependency_dirs.append(app.path) + self.execution_info.env_vars.update(app.env_vars) + + self.execution_info.to_json_file(self._execution_info_file) + + return 0 + + def get_inputs(self) -> list[Path]: + inputs: list[Path] = [package_version_file()] + if self._source_manifest_file.exists(): + inputs.append(self._source_manifest_file) + return inputs + + def get_outputs(self) -> list[Path]: + outputs: list[Path] = [self._output_manifest_file, self._execution_info_file] + outputs.extend(self.execution_info.install_dirs) + # Tracked so the step re-runs if any installed app directory is removed (e.g. uninstalled). + outputs.extend(self.execution_info.dependency_dirs) + return outputs + + def update_execution_context(self) -> None: + if self._execution_info_file.exists(): + execution_info = ScoopInstallExecutionInfo.from_json_file(self._execution_info_file) + unique_paths = list(dict.fromkeys(execution_info.install_dirs)) + self.execution_context.add_install_dirs(unique_paths) + if execution_info.env_vars: + self.execution_context.add_env_vars(execution_info.env_vars) diff --git a/tests/test_scoop_install.py b/tests/test_scoop_install.py index f78fe39..335cbec 100644 --- a/tests/test_scoop_install.py +++ b/tests/test_scoop_install.py @@ -1,82 +1,305 @@ -import json -from pathlib import Path -from unittest.mock import Mock, patch - -from py_app_dev.core.scoop_wrapper import InstalledScoopApp, ScoopWrapper - -from pypeline.steps.scoop_install import ScoopInstall - - -def test_scoop_install(execution_context: Mock) -> None: - # Setup the scoop_file.json and other initial configurations - scoop_file_json = execution_context.project_root_dir.joinpath("scoopfile.json") - scoop_file_json.write_text("") - - expected_paths = [Path("some/path/bin"), Path("some/path/env")] - expected_env_vars = {"TEST_VAR": "value", "ANOTHER_VAR": "123"} - - mock_scoop_wrapper = Mock(spec=ScoopWrapper) - mock_scoop_wrapper.install.return_value = [ - InstalledScoopApp( - name="app1", - version="1.0.0", - path=Path("some/path"), - bin_dirs=[Path("bin")], - env_add_path=[Path("env")], - env_vars=expected_env_vars, - manifest_file=Path("some/manifest"), - ) - ] - - # Patch the create_scoop_wrapper to return the mock_scoop_wrapper - with patch("pypeline.steps.scoop_install.platform.system", return_value="Windows"): - with patch("pypeline.steps.scoop_install.create_scoop_wrapper", return_value=mock_scoop_wrapper): - scoop_install = ScoopInstall(execution_context, execution_context.project_root_dir) - scoop_install.run() - - assert len(scoop_install.get_inputs()) == 2, "Two inputs are expected: scoopfile.json and the package __init__.py" - mock_scoop_wrapper.install.assert_called_once_with(scoop_file_json) - execution_info_file = execution_context.project_root_dir.joinpath("scoop_install_exec_info.json") - assert execution_info_file.exists(), "Execution info file shall exist" - - # Verify that both install_dirs and env_vars are stored in the JSON file - execution_info = json.loads(execution_info_file.read_text()) - assert execution_info["install_dirs"] == [str(path) for path in expected_paths] - assert execution_info["env_vars"] == expected_env_vars - - # Update the execution context with the install directories and verify the call - scoop_install.update_execution_context() - execution_context.add_install_dirs.assert_called_once_with(expected_paths) - - # Verify that environment variables were added to the execution context - execution_context.add_env_vars.assert_called_once_with(expected_env_vars) - - -def test_scoop_install_non_windows(execution_context: Mock) -> None: - # Setup - scoop_file_json = execution_context.project_root_dir.joinpath("scoopfile.json") - scoop_file_json.write_text("") - - # Patch platform.system to return "Linux" - with patch("pypeline.steps.scoop_install.platform.system", return_value="Linux"): - scoop_install = ScoopInstall(execution_context, execution_context.project_root_dir) - result = scoop_install.run() - - # Verify result is 0 (Success) - assert result == 0 - - # Verify execution info file exists and is empty - execution_info_file = execution_context.project_root_dir.joinpath("scoop_install_exec_info.json") - assert execution_info_file.exists(), "Execution info file shall exist" - - execution_info = json.loads(execution_info_file.read_text()) - assert execution_info["install_dirs"] == [] - assert execution_info.get("env_vars", {}) == {} - - # Verify update_execution_context - scoop_install.update_execution_context() - - # Should add empty list - execution_context.add_install_dirs.assert_called_with([]) - # Should not call add_env_vars, or call with empty - execution_context.add_env_vars.assert_not_called() +import json +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from py_app_dev.core.exceptions import UserNotificationException +from py_app_dev.core.scoop_wrapper import InstalledScoopApp, ScoopFileElement, ScoopWrapper + +from pypeline.domain.execution_context import ExecutionContext +from pypeline.main import package_version_file +from pypeline.steps.scoop_install import ScoopInstall, ScoopInstallExecutionInfo, ScoopManifest + + +def test_scoop_data() -> None: + scoop_content = { + "buckets": [{"Name": "my_bucket", "Source": "https://github.com/my/bucket"}], + "apps": [ + { + "Name": "app1", + "Source": "my_bucket", + "Version": "1.0.0", + }, + { + "name": "app2", + "source": "my_bucket", + "version": "2.0.0", + }, + ], + } + content = ScoopManifest.from_dict(scoop_content) + assert content.buckets[0].name == "my_bucket" + assert {app.name for app in content.apps} == {"app1", "app2"} + # Check serialization back to dict + assert json.loads(content.to_json_string()) == scoop_content + + +def test_scoop_manifest_file_from_file(tmp_path: Path) -> None: + scoop_content = { + "buckets": [{"Name": "main", "Source": "https://github.com/ScoopInstaller/Main"}, {"Name": "extras", "Source": "https://github.com/ScoopInstaller/Extras"}], + "apps": [{"Name": "git", "Source": "main", "Version": "2.42.0"}, {"Name": "vscode", "Source": "extras"}], + } + + scoop_file = tmp_path / "scoopfile.json" + scoop_file.write_text(json.dumps(scoop_content, indent=2)) + + manifest_file = ScoopManifest.from_file(scoop_file) + assert manifest_file.file == scoop_file + + assert len(manifest_file.buckets) == 2 + main_bucket = next((bucket for bucket in manifest_file.buckets if bucket.name == "main"), None) + assert main_bucket is not None + assert main_bucket.source == "https://github.com/ScoopInstaller/Main" + + extras_bucket = next((bucket for bucket in manifest_file.buckets if bucket.name == "extras"), None) + assert extras_bucket is not None + assert extras_bucket.source == "https://github.com/ScoopInstaller/Extras" + + assert len(manifest_file.apps) == 2 + git_app = next((app for app in manifest_file.apps if app.name == "git"), None) + assert git_app is not None + assert git_app.source == "main" + assert git_app.version == "2.42.0" + + vscode_app = next((app for app in manifest_file.apps if app.name == "vscode"), None) + assert vscode_app is not None + assert vscode_app.source == "extras" + assert vscode_app.version is None + + +def test_scoop_install_execution_info_serialization(tmp_path: Path) -> None: + execution_info = ScoopInstallExecutionInfo( + install_dirs=[tmp_path / "app1", tmp_path / "app2"], + dependency_dirs=[tmp_path / "app3"], + env_vars={"PATH": "/usr/bin", "EDITOR": "vim"}, + ) + + info_file = tmp_path / "execution_info.json" + execution_info.to_json_file(info_file) + assert info_file.exists() + + loaded_info = ScoopInstallExecutionInfo.from_json_file(info_file) + assert len(loaded_info.install_dirs) == 2 + assert tmp_path / "app1" in loaded_info.install_dirs + assert tmp_path / "app2" in loaded_info.install_dirs + assert loaded_info.dependency_dirs == [tmp_path / "app3"] + assert loaded_info.env_vars["PATH"] == "/usr/bin" + assert loaded_info.env_vars["EDITOR"] == "vim" + + +def test_scoop_install_with_no_dependencies(tmp_path: Path) -> None: + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + collected = scoop_install._collect_dependencies() + + assert len(collected.buckets) == 0 + assert len(collected.apps) == 0 + + +def test_scoop_install_with_global_scoopfile(tmp_path: Path) -> None: + global_scoop_content = { + "buckets": [{"Name": "global_bucket", "Source": "https://github.com/global/bucket"}], + "apps": [{"Name": "global_app", "Source": "global_bucket", "Version": "1.0.0"}], + } + (tmp_path / "scoopfile.json").write_text(json.dumps(global_scoop_content, indent=2)) + + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + collected = scoop_install._collect_dependencies() + + assert len(collected.buckets) == 1 + assert collected.buckets[0].name == "global_bucket" + assert collected.buckets[0].source == "https://github.com/global/bucket" + + assert len(collected.apps) == 1 + assert collected.apps[0].name == "global_app" + assert collected.apps[0].version == "1.0.0" + + +def test_scoop_install_raises_on_corrupt_scoopfile(tmp_path: Path) -> None: + (tmp_path / "scoopfile.json").write_text("{ this is not valid json") + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + with pytest.raises(UserNotificationException): + scoop_install._collect_dependencies() + + +def test_scoop_install_merges_multiple_sources(tmp_path: Path) -> None: + """A subclass can contribute additional manifest sources; buckets and apps from all sources merge.""" + source_content = {"buckets": [{"Name": "main", "Source": "https://github.com/ScoopInstaller/Main"}], "apps": [{"Name": "git", "Source": "main"}]} + (tmp_path / "scoopfile.json").write_text(json.dumps(source_content)) + extra_manifest = tmp_path / "extra.json" + extra_content = {"buckets": [{"Name": "extras", "Source": "https://github.com/ScoopInstaller/Extras"}], "apps": [{"Name": "vscode", "Source": "extras"}]} + extra_manifest.write_text(json.dumps(extra_content)) + + class MultiSourceScoopInstall(ScoopInstall[ExecutionContext]): + def _collect_dependencies(self) -> ScoopManifest: + manifest = super()._collect_dependencies() + extra = ScoopManifest.from_file(extra_manifest) + self._merge_buckets(manifest, extra.buckets) + self._merge_apps(manifest, extra.apps) + return manifest + + scoop_install = MultiSourceScoopInstall(ExecutionContext(project_root_dir=tmp_path), "install") + collected = scoop_install._collect_dependencies() + + assert {bucket.name for bucket in collected.buckets} == {"main", "extras"} + assert {app.name for app in collected.apps} == {"git", "vscode"} + + +def test_scoop_install_merges_buckets_with_conflicts(tmp_path: Path) -> None: + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + manifest = ScoopManifest( + buckets=[ScoopFileElement.from_dict({"name": "main", "source": "https://github.com/ScoopInstaller/Main"})], + apps=[], + ) + duplicate = ScoopManifest( + buckets=[ScoopFileElement.from_dict({"name": "main", "source": "https://github.com/different/main"})], + apps=[], + ) + + scoop_install._merge_buckets(manifest, duplicate.buckets) + + assert len(manifest.buckets) == 1 + assert manifest.buckets[0].source == "https://github.com/ScoopInstaller/Main" + + +def test_scoop_install_merges_apps_avoids_duplicates(tmp_path: Path) -> None: + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + target = ScoopManifest(apps=[ScoopFileElement.from_dict({"name": "git", "source": "main", "version": "2.42.0"})]) + source_apps = [ + ScoopFileElement.from_dict({"name": "git", "source": "main", "version": "2.42.0"}), + ScoopFileElement.from_dict({"name": "vscode", "source": "extras"}), + ] + + scoop_install._merge_apps(target, source_apps) + + assert {app.name for app in target.apps} == {"git", "vscode"} + + +def test_scoop_install_merges_apps_keeps_existing_on_version_conflict(tmp_path: Path) -> None: + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + target = ScoopManifest(apps=[ScoopFileElement.from_dict({"name": "git", "source": "main", "version": "2.42.0"})]) + conflicting = [ScoopFileElement.from_dict({"name": "git", "source": "main", "version": "2.43.0"})] + + scoop_install._merge_apps(target, conflicting) + + assert len(target.apps) == 1 + assert target.apps[0].version == "2.42.0" + + +def test_scoop_install_get_inputs_includes_package_version_file(tmp_path: Path) -> None: + (tmp_path / "scoopfile.json").write_text("{}") + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + inputs = scoop_install.get_inputs() + + # The package version file is always an input so the step re-runs on a pypeline upgrade. + assert package_version_file() in inputs + assert tmp_path / "scoopfile.json" in inputs + + +def test_scoop_install_run_skips_on_non_windows(tmp_path: Path) -> None: + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + with patch("pypeline.steps.scoop_install.platform.system", return_value="Linux"): + assert scoop_install.run() == 0 + + with patch("pypeline.steps.scoop_install.platform.system", return_value="Darwin"): + assert scoop_install.run() == 0 + + +def test_scoop_install_propagates_install_dirs_and_env_vars(tmp_path: Path) -> None: + """Installed apps' PATH directories and environment variables reach the execution context.""" + (tmp_path / "scoopfile.json").write_text(json.dumps({"apps": [{"Name": "compiler", "Source": "main"}]})) + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + app_root = tmp_path / "scoop" / "apps" / "compiler" / "current" + installed_app = InstalledScoopApp( + name="compiler", + version="1.0.0", + path=app_root, + bin_dirs=[Path("bin")], + env_add_path=[Path("lib")], + env_vars={"COMPILER_ROOT": str(app_root)}, + manifest_file=tmp_path / "manifest.json", + ) + wrapper = Mock(spec=ScoopWrapper) + wrapper.install.return_value = [installed_app] + + with patch("pypeline.steps.scoop_install.platform.system", return_value="Windows"): + with patch("pypeline.steps.scoop_install.create_scoop_wrapper", return_value=wrapper): + assert scoop_install.run() == 0 + + scoop_install.update_execution_context() + + # bin_dirs and env_add_path are resolved against the app root and added to PATH. + assert app_root / "bin" in exec_context.install_dirs + assert app_root / "lib" in exec_context.install_dirs + # Environment variables declared by the app are propagated too. + assert exec_context.env_vars["COMPILER_ROOT"] == str(app_root) + + +def test_scoop_install_generates_output_manifest(tmp_path: Path) -> None: + """run() writes the collected dependencies as a scoopfile.json into the output dir before installing.""" + source_content = {"buckets": [{"Name": "main", "Source": "https://github.com/ScoopInstaller/Main"}], "apps": [{"Name": "git", "Source": "main"}]} + (tmp_path / "scoopfile.json").write_text(json.dumps(source_content)) + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + wrapper = Mock(spec=ScoopWrapper) + wrapper.install.return_value = [] + + with patch("pypeline.steps.scoop_install.platform.system", return_value="Windows"): + with patch("pypeline.steps.scoop_install.create_scoop_wrapper", return_value=wrapper): + assert scoop_install.run() == 0 + + generated = ScoopManifest.from_file(scoop_install._output_manifest_file) + assert {bucket.name for bucket in generated.buckets} == {"main"} + assert {app.name for app in generated.apps} == {"git"} + wrapper.install.assert_called_once_with(scoop_install._output_manifest_file) + + +def test_scoop_install_tracks_app_dir_without_adding_to_path(tmp_path: Path) -> None: + """ + Regression for #13: an env-var-only app (no PATH dirs) must still be tracked as an output. + + Its install directory is recorded so an out-of-band `scoop uninstall` is detected on the + next run, but the app root must NOT be added to the execution context install dirs (PATH). + """ + (tmp_path / "scoopfile.json").write_text(json.dumps({"apps": [{"Name": "mytool", "Source": "main"}]})) + exec_context = ExecutionContext(project_root_dir=tmp_path) + scoop_install = ScoopInstall(exec_context, "install") + + app_dir = tmp_path / "scoop" / "apps" / "mytool" / "current" + installed_app = InstalledScoopApp( + name="mytool", + version="1.0.0", + path=app_dir, + bin_dirs=[], # no bin directories + env_add_path=[], # no directories added to PATH + env_vars={"MYTOOL_ROOT": str(app_dir)}, + manifest_file=tmp_path / "manifest.json", + ) + wrapper = Mock(spec=ScoopWrapper) + wrapper.install.return_value = [installed_app] + + with patch("pypeline.steps.scoop_install.platform.system", return_value="Windows"): + with patch("pypeline.steps.scoop_install.create_scoop_wrapper", return_value=wrapper): + assert scoop_install.run() == 0 + + # The app root is tracked as an output, so removing it (uninstall) re-triggers the step. + assert app_dir in scoop_install.get_outputs() + # ...but it is not propagated as an install dir, so it never lands on PATH. + scoop_install.update_execution_context() + assert app_dir not in exec_context.install_dirs