Download tests/test_tools.py from elyanlabs/trashclaw: direct link, hf CLI and curl.
- Browser
- Download file 18.4 kB
-
https://huggingface.co/elyanlabs/trashclaw/resolve/main/tests/test_tools.py
- Command line
-
hf download hf://elyanlabs/trashclaw/tests/test_tools.py
-
curl -L -o test_tools.py https://huggingface.co/elyanlabs/trashclaw/resolve/main/tests/test_tools.py
18.4 kB
| # SPDX-License-Identifier: MIT | |
| """Tests for TrashClaw core tool functions. | |
| Uses only pytest + stdlib. All file operations use tmp directories. | |
| """ | |
| import json | |
| import os | |
| import sys | |
| import subprocess | |
| import pytest | |
| # Add project root to path so we can import from trashclaw | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) | |
| import trashclaw | |
| # ββ Fixtures ββ | |
| def set_cwd(tmp_path, monkeypatch): | |
| """Set TrashClaw's CWD to a temp directory for all tests.""" | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| return tmp_path | |
| def sample_file(tmp_path): | |
| """Create a sample file for read/write tests.""" | |
| f = tmp_path / "sample.txt" | |
| f.write_text("line one\nline two\nline three\n") | |
| return f | |
| def sample_py(tmp_path): | |
| """Create a sample Python file for search tests.""" | |
| f = tmp_path / "hello.py" | |
| f.write_text('def greet(name):\n return f"Hello, {name}!"\n') | |
| return f | |
| # ββ tool_read_file ββ | |
| class TestReadFile: | |
| def test_read_full(self, sample_file): | |
| result = trashclaw.tool_read_file(str(sample_file)) | |
| assert "line one" in result | |
| assert "line two" in result | |
| assert "line three" in result | |
| def test_read_with_offset_limit(self, sample_file): | |
| result = trashclaw.tool_read_file(str(sample_file), offset=2, limit=1) | |
| assert "line two" in result | |
| # Should not contain line one or three (only line 2) | |
| assert "line one" not in result | |
| def test_read_nonexistent(self): | |
| result = trashclaw.tool_read_file("/tmp/nonexistent_trashclaw_test.txt") | |
| assert "error" in result.lower() or "not found" in result.lower() or "no such" in result.lower() | |
| # ββ tool_write_file ββ | |
| class TestWriteFile: | |
| def test_write_new(self, tmp_path): | |
| path = str(tmp_path / "new_file.txt") | |
| result = trashclaw.tool_write_file(path, "hello world") | |
| assert os.path.exists(path) | |
| assert "hello world" in open(path).read() | |
| def test_write_creates_dirs(self, tmp_path): | |
| path = str(tmp_path / "sub" / "dir" / "file.txt") | |
| trashclaw.tool_write_file(path, "nested content") | |
| assert os.path.exists(path) | |
| assert "nested content" in open(path).read() | |
| def test_write_overwrite(self, sample_file): | |
| trashclaw.tool_write_file(str(sample_file), "replaced") | |
| assert open(str(sample_file)).read() == "replaced" | |
| # ββ tool_edit_file ββ | |
| class TestEditFile: | |
| def test_edit_replace(self, sample_file): | |
| result = trashclaw.tool_edit_file(str(sample_file), "line two", "LINE TWO") | |
| content = open(str(sample_file)).read() | |
| assert "LINE TWO" in content | |
| assert "line two" not in content | |
| def test_edit_not_found(self, sample_file): | |
| result = trashclaw.tool_edit_file(str(sample_file), "nonexistent text", "replacement") | |
| assert "not found" in result.lower() or "error" in result.lower() or "no match" in result.lower() | |
| # ββ tool_run_command ββ | |
| class TestRunCommand: | |
| def test_echo(self, monkeypatch): | |
| # Bypass shell approval prompt | |
| monkeypatch.setattr(trashclaw, "APPROVE_SHELL", False) | |
| result = trashclaw.tool_run_command("echo hello_trashclaw_test") | |
| assert "hello_trashclaw_test" in result | |
| def test_exit_code(self, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "APPROVE_SHELL", False) | |
| result = trashclaw.tool_run_command("false") | |
| # Should contain some indication of failure or non-zero exit | |
| assert isinstance(result, str) | |
| class TestRunCommandAlwaysApprove: | |
| """An "always" approval must not cover chained/substituted commands.""" | |
| def prompts(self, monkeypatch, tmp_path): | |
| asked = [] | |
| def fake_input(prompt=""): | |
| asked.append(prompt) | |
| return "n" | |
| monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True) | |
| monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", {"echo"}) | |
| monkeypatch.setattr("builtins.input", fake_input) | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| return asked | |
| def test_simple_approved_command_runs_without_prompt(self, prompts): | |
| result = trashclaw.tool_run_command("echo approved_ok") | |
| assert "approved_ok" in result | |
| assert prompts == [] | |
| def test_metachar_command_still_prompts(self, prompts, command): | |
| result = trashclaw.tool_run_command(command) | |
| assert len(prompts) == 1 | |
| assert result == "Command cancelled by user." | |
| # Evasion attempts against the metacharacter check. subprocess runs | |
| # /bin/sh here; on Windows cmd.exe its chaining operators (& | < >) and | |
| # newlines are in the same set. | |
| def test_evasions_prompt_and_do_not_run(self, prompts, tmp_path, command): | |
| result = trashclaw.tool_run_command(command) | |
| assert len(prompts) == 1 | |
| assert result == "Command cancelled by user." | |
| assert not (tmp_path / "PWNED").exists() | |
| # Globbing and tilde expansion don't run anything: the approved command | |
| # runs without a prompt and nothing else is executed. | |
| def test_globs_run_only_the_approved_command(self, prompts, tmp_path): | |
| (tmp_path / "existing.txt").write_text("") | |
| trashclaw.tool_run_command("echo * ~ touch PWNED") | |
| assert prompts == [] | |
| assert not (tmp_path / "PWNED").exists() | |
| # Anything outside printable ASCII always prompts: Python's str.split() | |
| # breaks words on characters (\x0b, \x1c-\x1f, \x85, U+2028, ...) that | |
| # /bin/sh keeps inside a word, so the approval key and the program the | |
| # shell runs could differ. | |
| def test_non_ascii_or_control_characters_prompt(self, prompts, tmp_path, command): | |
| assert trashclaw.tool_run_command(command) == "Command cancelled by user." | |
| assert len(prompts) == 1 | |
| assert not (tmp_path / "PWNED").exists() | |
| def test_word_split_mismatch_cannot_run_a_planted_script(self, prompts, tmp_path, sep): | |
| """Reviewer's reproduction: 'echo<VT>/p' is keyed as 'echo' by str.split() | |
| but /bin/sh runs the planted script './echo<VT>/p'.""" | |
| planted = tmp_path / ("echo" + sep) | |
| planted.mkdir() | |
| script = planted / "p" | |
| script.write_text("#!/bin/sh\ntouch PWNED\n") | |
| script.chmod(0o755) | |
| assert trashclaw.tool_run_command("echo" + sep + "/p") == "Command cancelled by user." | |
| assert len(prompts) == 1 | |
| assert not (tmp_path / "PWNED").exists() | |
| class TestAlwaysOnlyForReadOnlyTools: | |
| """"always" is an allowlist of read-only tools; everything else prompts.""" | |
| def test_refused(self, prefix): | |
| assert trashclaw._allows_always(prefix) is False | |
| def test_allowed(self, prefix): | |
| assert trashclaw._allows_always(prefix) is True | |
| def test_escalating_commands_prompt_even_if_previously_approved( | |
| self, monkeypatch, tmp_path, command): | |
| asked = [] | |
| monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True) | |
| # Even if one of these somehow ended up approved, it must still prompt. | |
| monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", { | |
| "echo", "sqlite3", "psql", "hg", "sftp", "flock", "setsid", "stdbuf", | |
| "tmux", "Rscript", "pytest", | |
| }) | |
| monkeypatch.setattr("builtins.input", lambda prompt="": asked.append(prompt) or "n") | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| assert trashclaw.tool_run_command(command) == "Command cancelled by user." | |
| assert len(asked) == 1 | |
| assert not (tmp_path / "PWNED").exists() | |
| def test_always_answer_runs_once_without_persisting(self, monkeypatch, tmp_path): | |
| answers = iter(["a", "n"]) | |
| monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True) | |
| monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", set()) | |
| monkeypatch.setattr("builtins.input", lambda prompt="": next(answers)) | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| command = "sh -c 'echo ran_once'" | |
| assert "ran_once" in trashclaw.tool_run_command(command) | |
| assert trashclaw.APPROVED_COMMANDS == set() | |
| assert trashclaw.tool_run_command(command) == "Command cancelled by user." | |
| def test_always_answer_persists_for_read_only_tools(self, monkeypatch, tmp_path): | |
| monkeypatch.setattr(trashclaw, "APPROVE_SHELL", True) | |
| monkeypatch.setattr(trashclaw, "APPROVED_COMMANDS", set()) | |
| monkeypatch.setattr("builtins.input", lambda prompt="": "a") | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| trashclaw.tool_run_command("echo hi") | |
| assert trashclaw.APPROVED_COMMANDS == {"echo"} | |
| def test_command_key(self, command, key): | |
| assert trashclaw._command_key(command) == key | |
| # ββ tool_search_files ββ | |
| class TestSearchFiles: | |
| def test_search_content(self, sample_py, tmp_path): | |
| result = trashclaw.tool_search_files("greet", str(tmp_path)) | |
| assert "greet" in result | |
| def test_search_no_match(self, tmp_path): | |
| (tmp_path / "empty.txt").write_text("nothing here\n") | |
| result = trashclaw.tool_search_files("zzz_nonexistent_zzz", str(tmp_path)) | |
| assert "no match" in result.lower() or "0 match" in result.lower() or result.strip() == "" | |
| # ββ tool_find_files ββ | |
| class TestFindFiles: | |
| def test_find_by_glob(self, sample_py, tmp_path): | |
| result = trashclaw.tool_find_files("*.py", str(tmp_path)) | |
| assert "hello.py" in result | |
| def test_find_no_match(self, tmp_path): | |
| result = trashclaw.tool_find_files("*.xyz", str(tmp_path)) | |
| assert "hello.py" not in result | |
| # ββ tool_list_dir ββ | |
| class TestListDir: | |
| def test_list(self, sample_file, tmp_path): | |
| result = trashclaw.tool_list_dir(str(tmp_path)) | |
| assert "sample.txt" in result | |
| def test_list_empty(self, tmp_path): | |
| empty = tmp_path / "empty_dir" | |
| empty.mkdir() | |
| result = trashclaw.tool_list_dir(str(empty)) | |
| # Should not error, may show empty or "no files" | |
| assert isinstance(result, str) | |
| # ββ tool_git_status / tool_git_diff ββ | |
| class TestGitTools: | |
| def test_git_status_no_repo(self, tmp_path): | |
| """git status in a non-repo dir should handle gracefully.""" | |
| result = trashclaw.tool_git_status() | |
| # Either shows error or empty status | |
| assert isinstance(result, str) | |
| def test_git_diff_no_repo(self, tmp_path): | |
| result = trashclaw.tool_git_diff() | |
| assert isinstance(result, str) | |
| # ββ tool_patch_file ββ | |
| class TestPatchFile: | |
| def test_patch_simple(self, sample_file): | |
| patch = """--- a/sample.txt | |
| +++ b/sample.txt | |
| @@ -1,3 +1,3 @@ | |
| line one | |
| -line two | |
| +PATCHED LINE | |
| line three | |
| """ | |
| result = trashclaw.tool_patch_file(str(sample_file), patch) | |
| content = open(str(sample_file)).read() | |
| # patch_file may or may not succeed depending on implementation | |
| assert isinstance(result, str) | |
| # ββ _save_undo ββ | |
| class TestUndoSystem: | |
| def test_save_undo(self, sample_file, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "UNDO_STACK", []) | |
| trashclaw._save_undo(str(sample_file), "write") | |
| assert len(trashclaw.UNDO_STACK) >= 1 | |
| assert trashclaw.UNDO_STACK[-1]["path"] == str(sample_file) | |
| # ββ _track_tool ββ | |
| class TestAchievements: | |
| def test_track_tool(self, tmp_path, monkeypatch): | |
| # Use temp dir for achievements | |
| ach_file = str(tmp_path / "achievements.json") | |
| monkeypatch.setattr(trashclaw, "ACHIEVEMENTS_FILE", ach_file) | |
| monkeypatch.setattr(trashclaw, "ACHIEVEMENTS", trashclaw._load_achievements()) | |
| trashclaw._track_tool("read_file") | |
| # Should not crash; achievements tracked in memory | |
| assert True | |
| # ββ _load_config ββ | |
| class TestConfig: | |
| def test_load_config_no_file(self, tmp_path, monkeypatch): | |
| """Config loading should not crash when no config file exists.""" | |
| monkeypatch.setattr(trashclaw, "CONFIG_FILE", str(tmp_path / "nonexistent.json")) | |
| cfg = trashclaw._load_config(str(tmp_path)) | |
| assert isinstance(cfg, dict) | |
| def test_load_config_with_file(self, tmp_path, monkeypatch): | |
| config_file = tmp_path / "config.json" | |
| config_file.write_text(json.dumps({"model": "test-model"})) | |
| monkeypatch.setattr(trashclaw, "CONFIG_FILE", str(config_file)) | |
| cfg = trashclaw._load_config(str(tmp_path)) | |
| assert isinstance(cfg, dict) | |
| assert cfg.get("model") == "test-model" | |
| # ββ tool_clipboard ββ | |
| class TestClipboard: | |
| def test_clipboard_copy(self): | |
| """Clipboard copy should not crash even without display.""" | |
| result = trashclaw.tool_clipboard("copy", "test content") | |
| assert isinstance(result, str) | |
| # ββ tool_git_commit ββ | |
| class TestGitCommit: | |
| def test_git_commit_no_repo(self, tmp_path, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| result = trashclaw.tool_git_commit("test commit") | |
| assert isinstance(result, str) | |
| def test_git_commit_empty_message(self, tmp_path, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| result = trashclaw.tool_git_commit("") | |
| assert isinstance(result, str) | |
| def test_git_commit_in_real_repo(self, tmp_path, monkeypatch): | |
| import subprocess | |
| monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) | |
| subprocess.run(["git", "init"], cwd=str(tmp_path), capture_output=True) | |
| subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=str(tmp_path), capture_output=True) | |
| subprocess.run(["git", "config", "user.name", "Test"], cwd=str(tmp_path), capture_output=True) | |
| f = tmp_path / "test.txt" | |
| f.write_text("hello") | |
| subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True) | |
| result = trashclaw.tool_git_commit("initial commit") | |
| assert isinstance(result, str) | |
| # ββ tab completion ββ | |
| class TestReadOnlyMode: | |
| def test_available_tools_hides_side_effecting_tools(self, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "READ_ONLY_MODE", True) | |
| names = {tool["function"]["name"] for tool in trashclaw._available_tools()} | |
| assert "read_file" in names | |
| assert "search_files" in names | |
| assert "git_diff" in names | |
| assert "write_file" not in names | |
| assert "edit_file" not in names | |
| assert "patch_file" not in names | |
| assert "run_command" not in names | |
| assert "git_commit" not in names | |
| def test_read_only_blocks_write_and_shell(self, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "READ_ONLY_MODE", True) | |
| assert trashclaw._read_only_blocked("write_file", {}) | |
| assert trashclaw._read_only_blocked("run_command", {}) | |
| assert trashclaw._read_only_blocked("git_commit", {}) | |
| assert not trashclaw._read_only_blocked("read_file", {}) | |
| assert not trashclaw._read_only_blocked("git_status", {}) | |
| def test_read_only_allows_only_clipboard_paste(self, monkeypatch): | |
| monkeypatch.setattr(trashclaw, "READ_ONLY_MODE", True) | |
| assert not trashclaw._read_only_blocked("clipboard", {"action": "paste"}) | |
| assert trashclaw._read_only_blocked("clipboard", {"action": "copy"}) | |
| class TestTabCompletion: | |
| def test_setup_tab_completion_runs(self): | |
| try: | |
| trashclaw._setup_tab_completion() | |
| except Exception: | |
| pass # readline may not be available in CI | |
| def test_setup_tab_completion_returns_none(self): | |
| result = trashclaw._setup_tab_completion() | |
| assert result is None | |