| import os |
| import shutil |
| import subprocess |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| import pytest |
|
|
| FCC_COMMANDS = ( |
| "fcc-desktop", |
| "fcc-server", |
| "fcc-claude", |
| "fcc-codex", |
| "fcc-pi", |
| "fcc-init", |
| "free-claude-code", |
| ) |
|
|
|
|
| def _repo_root() -> Path: |
| return Path(__file__).resolve().parents[2] |
|
|
|
|
| def _write_executable(path: Path, text: str) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text(text, encoding="utf-8") |
| path.chmod(0o755) |
|
|
|
|
| def _powershells() -> tuple[str, ...]: |
| candidates = (shutil.which("pwsh"), shutil.which("powershell")) |
| return tuple(dict.fromkeys(path for path in candidates if path is not None)) |
|
|
|
|
| def _create_windows_shortcut( |
| powershell: str, |
| shortcut_path: Path, |
| target_path: Path, |
| ) -> None: |
| shortcut_path.parent.mkdir(parents=True, exist_ok=True) |
| env = os.environ | { |
| "FCC_TEST_SHORTCUT": str(shortcut_path), |
| "FCC_TEST_TARGET": str(target_path), |
| } |
| subprocess.run( |
| [ |
| powershell, |
| "-NoProfile", |
| "-Command", |
| ( |
| "$shell = New-Object -ComObject WScript.Shell; " |
| "$shortcut = $shell.CreateShortcut($env:FCC_TEST_SHORTCUT); " |
| "$shortcut.TargetPath = $env:FCC_TEST_TARGET; " |
| "$shortcut.Save()" |
| ), |
| ], |
| check=True, |
| capture_output=True, |
| text=True, |
| env=env, |
| ) |
|
|
|
|
| @dataclass |
| class PosixUninstallHarness: |
| home: Path |
| bin_dir: Path |
| tool_bin: Path |
| fcc_home: Path |
| log: Path |
| env: dict[str, str] |
|
|
| def run( |
| self, |
| *args: str, |
| fail_step: str = "", |
| include_uv: bool = True, |
| ) -> subprocess.CompletedProcess[str]: |
| uv = self.bin_dir / "uv" |
| if not include_uv and uv.exists(): |
| uv.unlink() |
| return subprocess.run( |
| ["/bin/sh", str(_repo_root() / "scripts" / "uninstall.sh"), *args], |
| check=False, |
| capture_output=True, |
| text=True, |
| env=self.env | {"FAIL_STEP": fail_step}, |
| ) |
|
|
| def calls(self) -> list[str]: |
| if not self.log.exists(): |
| return [] |
| return self.log.read_text(encoding="utf-8").splitlines() |
|
|
| def remove_entry_points(self) -> None: |
| for name in FCC_COMMANDS: |
| (self.tool_bin / name).unlink(missing_ok=True) |
|
|
| def use_process_list_fallback(self, process_line: str) -> None: |
| fallback_bin = self.home.parent / "fallback-bin" |
| fallback_bin.mkdir() |
| _write_executable( |
| fallback_bin / "ps", |
| """#!/bin/sh |
| printf '%s\n' "$FCC_PS_OUTPUT" |
| """, |
| ) |
| awk = shutil.which("awk", path=self.env["PATH"]) |
| if awk is None: |
| pytest.skip("awk is required for the POSIX process fallback scenario") |
| shutil.copy2(awk, fallback_bin / "awk") |
| self.env["FCC_PS_OUTPUT"] = process_line |
| self.env["PATH"] = str(fallback_bin) |
|
|
|
|
| @pytest.fixture |
| def posix_uninstall_harness(tmp_path: Path) -> PosixUninstallHarness: |
| if os.name == "nt": |
| pytest.skip("POSIX uninstaller scenarios run on POSIX hosts") |
|
|
| home = tmp_path / "home" |
| bin_dir = home / ".local" / "bin" |
| tool_bin = tmp_path / "tool-bin" |
| fcc_home = home / ".fcc" |
| log = tmp_path / "calls.log" |
| for path in (bin_dir, tool_bin, fcc_home): |
| path.mkdir(parents=True) |
| (fcc_home / "config.json").write_text("{}", encoding="utf-8") |
| for name in FCC_COMMANDS: |
| _write_executable(tool_bin / name, "#!/bin/sh\nexit 0\n") |
|
|
| _write_executable(bin_dir / "claude", "#!/bin/sh\nexit 0\n") |
| _write_executable(bin_dir / "codex", "#!/bin/sh\nexit 0\n") |
| _write_executable(bin_dir / "pi", "#!/bin/sh\nexit 0\n") |
| _write_executable( |
| bin_dir / "uv", |
| """#!/bin/sh |
| echo "uv:$*" >> "$CALL_LOG" |
| if [ "${1:-}" = "tool" ] && [ "${2:-}" = "dir" ] && [ "${3:-}" = "--bin" ]; then |
| if [ "$FAIL_STEP" = "tool-dir" ]; then |
| echo "tool directory unavailable" >&2 |
| exit 41 |
| fi |
| printf '%s\n' "$FAKE_TOOL_BIN" |
| exit 0 |
| fi |
| if [ "${1:-}" = "tool" ] && [ "${2:-}" = "uninstall" ]; then |
| if [ "$FAIL_STEP" = "uninstall" ]; then |
| echo "permission denied while removing tool" >&2 |
| exit 42 |
| fi |
| if [ "$FAIL_STEP" = "missing" ] || [ "$FAIL_STEP" = "stale-entrypoint" ]; then |
| echo 'Tool `free-claude-code` is not installed' >&2 |
| exit 2 |
| fi |
| for name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-init free-claude-code; do |
| /bin/rm -f "$FAKE_TOOL_BIN/$name" |
| done |
| echo "Uninstalled free-claude-code" |
| exit 0 |
| fi |
| exit 43 |
| """, |
| ) |
| _write_executable( |
| bin_dir / "rm", |
| """#!/bin/sh |
| echo "rm:$*" >> "$CALL_LOG" |
| if [ "$FAIL_STEP" = "purge" ] && [ "$*" = "-rf $HOME/.fcc" ]; then |
| echo "simulated purge failure" >&2 |
| exit 44 |
| fi |
| exec /bin/rm "$@" |
| """, |
| ) |
| _write_executable( |
| bin_dir / "uname", |
| """#!/bin/sh |
| printf '%s\n' "$FAKE_UNAME" |
| """, |
| ) |
|
|
| app = home / "Applications" / "Free Claude Code.app" |
| contents = app / "Contents" |
| contents.mkdir(parents=True) |
| (contents / ".free-claude-code-owner").write_text( |
| "io.github.alishahryar1.free-claude-code\n", |
| encoding="utf-8", |
| ) |
| desktop = home / "Desktop" |
| desktop.mkdir() |
| (desktop / "Free Claude Code.app").symlink_to(app, target_is_directory=True) |
|
|
| env = os.environ.copy() |
| env.update( |
| { |
| "HOME": str(home), |
| "PATH": f"{bin_dir}:/usr/bin:/bin", |
| "CALL_LOG": str(log), |
| "FAKE_TOOL_BIN": str(tool_bin), |
| "FAKE_UNAME": "Darwin", |
| "FAIL_STEP": "", |
| } |
| ) |
| env.pop("XDG_BIN_HOME", None) |
| return PosixUninstallHarness(home, bin_dir, tool_bin, fcc_home, log, env) |
|
|
|
|
| def test_uninstall_sh_removes_and_verifies_only_fcc( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| result = posix_uninstall_harness.run() |
|
|
| assert result.returncode == 0, result.stderr |
| assert "Free Claude Code has been removed and verified." in result.stdout |
| assert not posix_uninstall_harness.fcc_home.exists() |
| assert all( |
| not (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS |
| ) |
| assert (posix_uninstall_harness.bin_dir / "uv").exists() |
| assert (posix_uninstall_harness.bin_dir / "claude").exists() |
| assert (posix_uninstall_harness.bin_dir / "codex").exists() |
| assert (posix_uninstall_harness.bin_dir / "pi").exists() |
| assert posix_uninstall_harness.calls() == [ |
| "uv:tool dir --bin", |
| "uv:tool uninstall free-claude-code", |
| f"rm:-f {posix_uninstall_harness.home / 'Desktop' / 'Free Claude Code.app'}", |
| f"rm:-rf {posix_uninstall_harness.home / 'Applications' / 'Free Claude Code.app'}", |
| f"rm:-rf {posix_uninstall_harness.fcc_home}", |
| ] |
|
|
|
|
| def test_uninstall_sh_is_idempotent_when_tool_is_already_absent( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| posix_uninstall_harness.remove_entry_points() |
|
|
| result = posix_uninstall_harness.run(fail_step="missing") |
|
|
| assert result.returncode == 0, result.stderr |
| assert not posix_uninstall_harness.fcc_home.exists() |
| assert "already absent" in result.stdout |
|
|
|
|
| def test_uninstall_sh_preserves_unrelated_macos_desktop_link( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| desktop_link = posix_uninstall_harness.home / "Desktop" / "Free Claude Code.app" |
| desktop_link.unlink() |
| unrelated = posix_uninstall_harness.home / "Unrelated.app" |
| unrelated.mkdir() |
| desktop_link.symlink_to(unrelated, target_is_directory=True) |
|
|
| result = posix_uninstall_harness.run() |
|
|
| assert result.returncode == 0, result.stderr |
| assert "non-FCC link" in result.stdout |
| assert desktop_link.readlink() == unrelated |
|
|
|
|
| def test_uninstall_sh_preserves_unowned_macos_app_bundle( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| app = posix_uninstall_harness.home / "Applications" / "Free Claude Code.app" |
| owner_file = app / "Contents" / ".free-claude-code-owner" |
| owner_file.unlink() |
| sentinel = app / "foreign.txt" |
| sentinel.write_text("keep", encoding="utf-8") |
| desktop_link = posix_uninstall_harness.home / "Desktop" / "Free Claude Code.app" |
|
|
| result = posix_uninstall_harness.run() |
|
|
| assert result.returncode == 0, result.stderr |
| assert "not managed by Free Claude Code" in result.stdout |
| assert sentinel.read_text(encoding="utf-8") == "keep" |
| assert desktop_link.is_symlink() |
|
|
|
|
| def test_uninstall_sh_does_not_touch_macos_paths_on_linux( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| posix_uninstall_harness.env["FAKE_UNAME"] = "Linux" |
| app = posix_uninstall_harness.home / "Applications" / "Free Claude Code.app" |
| desktop_link = posix_uninstall_harness.home / "Desktop" / "Free Claude Code.app" |
|
|
| result = posix_uninstall_harness.run() |
|
|
| assert result.returncode == 0, result.stderr |
| assert app.is_dir() |
| assert desktop_link.is_symlink() |
|
|
|
|
| @pytest.mark.parametrize("failure", ["tool-dir", "uninstall", "stale-entrypoint"]) |
| def test_uninstall_sh_preserves_config_when_tool_removal_is_unconfirmed( |
| posix_uninstall_harness: PosixUninstallHarness, |
| failure: str, |
| ) -> None: |
| result = posix_uninstall_harness.run(fail_step=failure) |
|
|
| assert result.returncode != 0 |
| assert posix_uninstall_harness.fcc_home.exists() |
| assert "Free Claude Code has been removed and verified." not in result.stdout |
| assert not any(call.startswith("rm:") for call in posix_uninstall_harness.calls()) |
|
|
|
|
| def test_uninstall_sh_requires_uv_before_deleting_config( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| result = posix_uninstall_harness.run(include_uv=False) |
|
|
| assert result.returncode != 0 |
| assert posix_uninstall_harness.fcc_home.exists() |
| assert "uv is required" in result.stderr |
| assert posix_uninstall_harness.calls() == [] |
|
|
|
|
| def test_uninstall_sh_reports_purge_failure_after_verified_tool_removal( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| result = posix_uninstall_harness.run(fail_step="purge") |
|
|
| assert result.returncode != 0 |
| assert posix_uninstall_harness.fcc_home.exists() |
| assert all( |
| not (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS |
| ) |
| assert "Free Claude Code has been removed and verified." not in result.stdout |
|
|
|
|
| def test_uninstall_sh_dry_run_is_non_mutating( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| result = posix_uninstall_harness.run("--dry-run") |
|
|
| assert result.returncode == 0, result.stderr |
| assert posix_uninstall_harness.fcc_home.exists() |
| assert all( |
| (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS |
| ) |
| assert posix_uninstall_harness.calls() == [] |
| assert "Dry run complete. No changes were made." in result.stdout |
|
|
|
|
| def test_uninstall_sh_rejects_invalid_options_before_mutation( |
| posix_uninstall_harness: PosixUninstallHarness, |
| ) -> None: |
| result = posix_uninstall_harness.run("--unknown") |
|
|
| assert result.returncode != 0 |
| assert posix_uninstall_harness.fcc_home.exists() |
| assert posix_uninstall_harness.calls() == [] |
|
|
|
|
| @pytest.mark.parametrize( |
| ("command_name", "process_args"), |
| ( |
| ("free-claude-code", "/home/user/.local/bin/free-claude-code"), |
| ("fcc-server", "/usr/bin/python3 /home/user/.local/bin/fcc-server"), |
| ), |
| ) |
| def test_uninstall_sh_process_fallback_reads_full_command_line( |
| posix_uninstall_harness: PosixUninstallHarness, |
| command_name: str, |
| process_args: str, |
| ) -> None: |
| posix_uninstall_harness.use_process_list_fallback(f"4242 {process_args}") |
|
|
| result = posix_uninstall_harness.run() |
|
|
| assert result.returncode != 0 |
| assert posix_uninstall_harness.fcc_home.exists() |
| assert posix_uninstall_harness.calls() == [] |
| assert command_name in result.stderr |
|
|
|
|
| @dataclass |
| class PowerShellUninstallHarness: |
| home: Path |
| bin_dir: Path |
| tool_bin: Path |
| fcc_home: Path |
| log: Path |
| env: dict[str, str] |
| powershell: str |
| wrapper: Path |
|
|
| def run( |
| self, |
| *, |
| fail_step: str = "", |
| include_uv: bool = True, |
| dry_run: bool = False, |
| ) -> subprocess.CompletedProcess[str]: |
| uv = self.bin_dir / "uv.cmd" |
| if not include_uv and uv.exists(): |
| uv.unlink() |
| return subprocess.run( |
| [ |
| self.powershell, |
| "-NoProfile", |
| "-ExecutionPolicy", |
| "Bypass", |
| "-File", |
| str(self.wrapper), |
| ], |
| check=False, |
| capture_output=True, |
| text=True, |
| env=self.env |
| | { |
| "FAIL_STEP": fail_step, |
| "UNINSTALL_DRY_RUN": "1" if dry_run else "0", |
| }, |
| ) |
|
|
| def calls(self) -> list[str]: |
| if not self.log.exists(): |
| return [] |
| return self.log.read_text(encoding="utf-8").splitlines() |
|
|
| def remove_entry_points(self) -> None: |
| for name in FCC_COMMANDS: |
| (self.tool_bin / f"{name}.cmd").unlink(missing_ok=True) |
|
|
|
|
| @pytest.fixture( |
| params=_powershells() or (None,), |
| ids=lambda path: Path(path).name if path is not None else "unavailable", |
| ) |
| def powershell_uninstall_harness( |
| tmp_path: Path, |
| request: pytest.FixtureRequest, |
| ) -> PowerShellUninstallHarness: |
| powershell = request.param |
| if powershell is None or os.name != "nt": |
| pytest.skip("PowerShell uninstaller scenarios run on Windows hosts") |
|
|
| home = tmp_path / "home" |
| bin_dir = home / ".local" / "bin" |
| tool_bin = tmp_path / "tool-bin" |
| fcc_home = home / ".fcc" |
| app_data = tmp_path / "app-data" |
| log = tmp_path / "calls.log" |
| for path in (bin_dir, tool_bin, fcc_home, app_data): |
| path.mkdir(parents=True) |
| (fcc_home / "config.json").write_text("{}", encoding="utf-8") |
| for name in FCC_COMMANDS: |
| (tool_bin / f"{name}.cmd").write_text( |
| "@echo off\nexit /b 0\n", encoding="utf-8" |
| ) |
| for name in ("claude", "codex", "pi"): |
| (bin_dir / f"{name}.cmd").write_text("@echo off\nexit /b 0\n", encoding="utf-8") |
|
|
| uv_commands = " ".join(FCC_COMMANDS) |
| (bin_dir / "uv.cmd").write_text( |
| rf"""@echo off |
| echo uv:%*>>"%CALL_LOG%" |
| if "%1"=="tool" if "%2"=="dir" if "%3"=="--bin" goto tool_bin |
| if "%1"=="tool" if "%2"=="uninstall" goto uninstall |
| exit /b 53 |
| :tool_bin |
| if "%FAIL_STEP%"=="tool-dir" echo tool directory unavailable 1>&2 & exit /b 51 |
| echo %FAKE_TOOL_BIN% |
| exit /b 0 |
| :uninstall |
| if "%FAIL_STEP%"=="uninstall" echo permission denied while removing tool 1>&2 & exit /b 52 |
| if "%FAIL_STEP%"=="missing" echo Tool `free-claude-code` is not installed 1>&2 & exit /b 2 |
| if "%FAIL_STEP%"=="stale-entrypoint" echo Tool `free-claude-code` is not installed 1>&2 & exit /b 2 |
| for %%C in ({uv_commands}) do del /q "%FAKE_TOOL_BIN%\%%C.cmd" 2>nul |
| echo Uninstalled free-claude-code |
| exit /b 0 |
| """, |
| encoding="utf-8", |
| ) |
|
|
| wrapper = tmp_path / "run-uninstaller.ps1" |
| wrapper.write_text( |
| r"""Set-StrictMode -Version Latest |
| $ErrorActionPreference = "Stop" |
| function Remove-Item { |
| [CmdletBinding()] |
| param( |
| [string] $LiteralPath, |
| [switch] $Recurse, |
| [switch] $Force |
| ) |
| Add-Content -LiteralPath $env:CALL_LOG -Value "remove:$LiteralPath" |
| if ( |
| $env:FAIL_STEP -eq "purge" -and |
| $LiteralPath -eq (Join-Path $env:USERPROFILE ".fcc") |
| ) { |
| throw "simulated purge failure" |
| } |
| Microsoft.PowerShell.Management\Remove-Item @PSBoundParameters |
| } |
| $installer = [scriptblock]::Create([IO.File]::ReadAllText($env:FCC_UNINSTALLER)) |
| if ($env:UNINSTALL_DRY_RUN -eq "1") { |
| & $installer -DryRun |
| } |
| else { |
| & $installer |
| } |
| """, |
| encoding="utf-8", |
| ) |
|
|
| desktop_shortcut = home / "Desktop" / "Free Claude Code.lnk" |
| start_shortcut = ( |
| app_data |
| / "Microsoft" |
| / "Windows" |
| / "Start Menu" |
| / "Programs" |
| / "Free Claude Code.lnk" |
| ) |
| for shortcut in (desktop_shortcut, start_shortcut): |
| _create_windows_shortcut( |
| powershell, |
| shortcut, |
| tool_bin / "fcc-desktop.cmd", |
| ) |
|
|
| system_root = os.environ["SYSTEMROOT"] |
| env = os.environ.copy() |
| env.update( |
| { |
| "PATH": os.pathsep.join( |
| [str(bin_dir), str(Path(system_root) / "System32"), system_root] |
| ), |
| "PATHEXT": ".COM;.EXE;.BAT;.CMD", |
| "HOME": str(home), |
| "USERPROFILE": str(home), |
| "APPDATA": str(app_data), |
| "CALL_LOG": str(log), |
| "FAKE_TOOL_BIN": str(tool_bin), |
| "FCC_UNINSTALLER": str(_repo_root() / "scripts" / "uninstall.ps1"), |
| "FAIL_STEP": "", |
| "UNINSTALL_DRY_RUN": "0", |
| } |
| ) |
| return PowerShellUninstallHarness( |
| home, bin_dir, tool_bin, fcc_home, log, env, powershell, wrapper |
| ) |
|
|
|
|
| def test_uninstall_ps1_removes_and_verifies_only_fcc( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| ) -> None: |
| result = powershell_uninstall_harness.run() |
|
|
| assert result.returncode == 0, result.stderr |
| assert "Free Claude Code has been removed and verified." in result.stdout |
| assert not powershell_uninstall_harness.fcc_home.exists() |
| assert all( |
| not (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() |
| for name in FCC_COMMANDS |
| ) |
| assert (powershell_uninstall_harness.bin_dir / "uv.cmd").exists() |
| assert (powershell_uninstall_harness.bin_dir / "claude.cmd").exists() |
| assert (powershell_uninstall_harness.bin_dir / "codex.cmd").exists() |
| assert (powershell_uninstall_harness.bin_dir / "pi.cmd").exists() |
| assert powershell_uninstall_harness.calls() == [ |
| "uv:tool dir --bin", |
| "uv:tool uninstall free-claude-code", |
| f"remove:{Path(powershell_uninstall_harness.env['USERPROFILE']) / 'Desktop' / 'Free Claude Code.lnk'}", |
| f"remove:{Path(powershell_uninstall_harness.env['APPDATA']) / 'Microsoft' / 'Windows' / 'Start Menu' / 'Programs' / 'Free Claude Code.lnk'}", |
| f"remove:{powershell_uninstall_harness.fcc_home}", |
| ] |
|
|
|
|
| def test_uninstall_ps1_preserves_unowned_desktop_shortcut( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| ) -> None: |
| desktop_shortcut = ( |
| Path(powershell_uninstall_harness.env["USERPROFILE"]) |
| / "Desktop" |
| / "Free Claude Code.lnk" |
| ) |
| unrelated_target = powershell_uninstall_harness.home / "unrelated.cmd" |
| unrelated_target.write_text("@echo off\n", encoding="utf-8") |
| _create_windows_shortcut( |
| powershell_uninstall_harness.powershell, |
| desktop_shortcut, |
| unrelated_target, |
| ) |
| original_shortcut = desktop_shortcut.read_bytes() |
|
|
| result = powershell_uninstall_harness.run() |
|
|
| assert result.returncode == 0, result.stderr |
| assert "not managed by Free Claude Code" in result.stdout |
| assert desktop_shortcut.read_bytes() == original_shortcut |
|
|
|
|
| def test_uninstall_ps1_is_idempotent_when_tool_is_already_absent( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| ) -> None: |
| powershell_uninstall_harness.remove_entry_points() |
|
|
| result = powershell_uninstall_harness.run(fail_step="missing") |
|
|
| assert result.returncode == 0, result.stderr |
| assert not powershell_uninstall_harness.fcc_home.exists() |
| assert "already absent" in result.stdout |
|
|
|
|
| @pytest.mark.parametrize("failure", ["tool-dir", "uninstall", "stale-entrypoint"]) |
| def test_uninstall_ps1_preserves_config_when_tool_removal_is_unconfirmed( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| failure: str, |
| ) -> None: |
| result = powershell_uninstall_harness.run(fail_step=failure) |
|
|
| assert result.returncode != 0 |
| assert powershell_uninstall_harness.fcc_home.exists() |
| assert "Free Claude Code has been removed and verified." not in result.stdout |
| assert not any( |
| call.startswith("remove:") for call in powershell_uninstall_harness.calls() |
| ) |
|
|
|
|
| def test_uninstall_ps1_requires_uv_before_deleting_config( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| ) -> None: |
| result = powershell_uninstall_harness.run(include_uv=False) |
|
|
| assert result.returncode != 0 |
| assert powershell_uninstall_harness.fcc_home.exists() |
| assert "uv is required" in result.stderr |
| assert powershell_uninstall_harness.calls() == [] |
|
|
|
|
| def test_uninstall_ps1_reports_purge_failure_after_verified_tool_removal( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| ) -> None: |
| result = powershell_uninstall_harness.run(fail_step="purge") |
|
|
| assert result.returncode != 0 |
| assert powershell_uninstall_harness.fcc_home.exists() |
| assert all( |
| not (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() |
| for name in FCC_COMMANDS |
| ) |
| assert "Free Claude Code has been removed and verified." not in result.stdout |
|
|
|
|
| def test_uninstall_ps1_dry_run_is_non_mutating( |
| powershell_uninstall_harness: PowerShellUninstallHarness, |
| ) -> None: |
| result = powershell_uninstall_harness.run(dry_run=True) |
|
|
| assert result.returncode == 0, result.stderr |
| assert powershell_uninstall_harness.fcc_home.exists() |
| assert all( |
| (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() |
| for name in FCC_COMMANDS |
| ) |
| assert powershell_uninstall_harness.calls() == [] |
| assert "Dry run complete. No changes were made." in result.stdout |
|
|
|
|
| def test_uninstallers_guard_running_commands_and_preserve_shared_owners() -> None: |
| shell = (_repo_root() / "scripts" / "uninstall.sh").read_text(encoding="utf-8") |
| powershell = (_repo_root() / "scripts" / "uninstall.ps1").read_text( |
| encoding="utf-8" |
| ) |
|
|
| assert "pgrep" in shell |
| assert "Get-Process" in powershell |
| for text in (shell, powershell): |
| for command in FCC_COMMANDS: |
| assert command in text |
| assert "npm uninstall" not in text |
| assert "uv self uninstall" not in text |
| assert "uv python uninstall" not in text |
| assert "is not installed" in text |
| assert "no tool" not in text |
| assert "nothing to uninstall" not in text |
|
|