Files
openworker/tests/test_code_tools.py
Mohamed Salah 5300583413 fix: search workspaces under ignored directory names (#576)
ripgrep matches --glob patterns against paths relative to the process
cwd, not the search root. Passing the absolute workspace path while the
agent runs elsewhere made the exclusion globs (!**/node_modules/** and
friends) match the workspace's own ancestors, so a workspace located
under e.g. .../node_modules/ silently excluded itself: rg exited 1 with
no output.

Run rg with cwd anchored to the search base and '.' as the target so
only directories inside the workspace can match, and resolve rg's now
relative output paths against that base before relativizing to the
workspace root.

Regression-tested with a workspace nested under node_modules; the
existing glob/escape/ignore tests are unchanged.
2026-09-26 14:54:05 +03:00

210 lines
8.1 KiB
Python

"""Tests for the Code agent's new tools: grep (ripgrep/python), git_log, web_fetch.
No network: web_fetch is exercised via its URL guard + the HTML→text helper. grep/git_log run
against temp dirs (git_log needs a real `git`, which the dev box has).
"""
from __future__ import annotations
import subprocess
from types import SimpleNamespace
import pytest
from coworker.tools.files import file_tools
from coworker.tools.git import git_tools
from coworker.tools.search import _parse_rg, _py_grep, search_tools
from coworker.web.fetch import _html_to_text, make_web_fetch_tool
# -- grep ----------------------------------------------------------------------
def _seed(tmp_path):
(tmp_path / "a.py").write_text("def hello():\n return 42\n", encoding="utf-8")
(tmp_path / "b.txt").write_text("hello world\nbye\n", encoding="utf-8")
nm = tmp_path / "node_modules" / "pkg"
nm.mkdir(parents=True)
(nm / "junk.py").write_text("hello from deps\n", encoding="utf-8")
def test_grep_finds_matches_and_respects_glob(tmp_path):
_seed(tmp_path)
grep = search_tools(str(tmp_path))[0]
out = grep(pattern="hello")
files = {m["file"] for m in out["matches"]}
assert "a.py" in files and "b.txt" in files
# node_modules is ignored by both engines
assert not any("node_modules" in f for f in files)
# glob filter restricts to python files
only_py = grep(pattern="hello", glob="*.py")
assert all(m["file"].endswith(".py") for m in only_py["matches"])
assert not any("node_modules" in m["file"] for m in only_py["matches"])
assert only_py["matches"][0]["line"] == 1
def test_parse_rg_handles_windows_drive_letter_paths():
# `rg --null` emits `<path>\0<line>:<text>`. A Windows absolute path leads with a
# drive-letter colon (C:\...); the old `split(":", 2)` mis-parsed it as file="C",
# line=0, text="12:...". Regression for issue #17 — asserts hold on every platform.
from pathlib import Path
stdout = "C:\\ws\\a.py\x0012:def hello():\n"
out = _parse_rg(stdout, Path("C:\\ws"), 100)
assert out["count"] == 1
m = out["matches"][0]
assert m["line"] == 12
assert m["text"] == "def hello():"
# the whole path survives (not truncated to the drive letter "C"); .endswith
# stays platform-independent — on Linux _rel can't relativize a `C:\` path, so it
# returns the raw string, which still ends with the filename.
assert m["file"].endswith("a.py")
def test_grep_works_when_workspace_sits_under_an_ignored_dir_name(tmp_path):
# Regression for #576: ripgrep matches --glob patterns against paths
# relative to the *current working directory*, not the search root, so a
# workspace living under e.g. .../node_modules/ws excluded itself entirely.
ws = tmp_path / "node_modules" / "ws"
ws.mkdir(parents=True)
(ws / "a.py").write_text("hello under ignored parent\n", encoding="utf-8")
grep = search_tools(str(ws))[0]
out = grep(pattern="hello")
assert out["count"] == 1
assert out["matches"][0]["file"] == "a.py"
def test_ripgrep_uses_the_same_ignored_dirs_as_the_python_fallback(tmp_path, monkeypatch):
import coworker.tools.search as search
commands = []
monkeypatch.setattr(search.shutil, "which", lambda name: "rg")
monkeypatch.setattr(
search.subprocess,
"run",
lambda cmd, **kwargs: commands.append(cmd)
or SimpleNamespace(returncode=0, stdout="", stderr=""),
)
search_tools(str(tmp_path))[0](pattern="hello", glob="*.py")
assert commands
user_glob = commands[0].index("*.py")
for ignored in search._IGNORE_DIRS:
assert commands[0].index(f"!**/{ignored}/**") > user_glob
def test_grep_rejects_path_escape(tmp_path):
grep = search_tools(str(tmp_path))[0]
assert "escapes" in grep(pattern="x", path="../..")["error"]
def test_py_grep_fallback_skips_ignored_dirs(tmp_path):
_seed(tmp_path)
res = _py_grep(tmp_path.resolve(), tmp_path.resolve(), "hello", None, 100)
assert res["count"] == 2 # a.py + b.txt, NOT node_modules
assert all("node_modules" not in m["file"] for m in res["matches"])
# -- git_log -------------------------------------------------------------------
def test_git_log_lists_commits(tmp_path):
ws = tmp_path / "repo"
ws.mkdir()
run = lambda *a: subprocess.run(
["git", "-C", str(ws), *a], capture_output=True, check=True
)
run("init", "-q")
run("config", "user.email", "t@t.io")
run("config", "user.name", "T")
(ws / "f.txt").write_text("1", encoding="utf-8")
run("add", "-A")
run("commit", "-qm", "first")
(ws / "f.txt").write_text("2", encoding="utf-8")
run("add", "-A")
run("commit", "-qm", "second")
git_log = git_tools(str(ws))[0]
out = git_log(max_count=10)
assert out["count"] == 2
assert out["commits"][0]["subject"] == "second" # newest first
assert set(out["commits"][0]) == {"hash", "author", "date", "subject"}
def test_git_log_errors_outside_repo(tmp_path):
git_log = git_tools(str(tmp_path))[0]
assert "error" in git_log()
# -- read_file -----------------------------------------------------------------
def test_read_file_numbers_lines(tmp_path):
(tmp_path / "a.py").write_text("one\ntwo\nthree\n", encoding="utf-8")
read_file = file_tools(str(tmp_path))[0]
out = read_file(path="a.py")
assert out["total_lines"] == 3
assert out["start_line"] == 1 and out["end_line"] == 3
assert out["content"].splitlines()[0].endswith("1\tone")
assert "note" not in out # nothing left to read
def test_read_file_windows_and_tells_how_to_continue(tmp_path):
(tmp_path / "big.txt").write_text(
"\n".join(f"line{i}" for i in range(1, 51)) + "\n", encoding="utf-8"
)
read_file = file_tools(str(tmp_path))[0]
out = read_file(path="big.txt", start_line=5, max_lines=10)
assert out["start_line"] == 5 and out["end_line"] == 14
assert out["total_lines"] == 50
assert "line5" in out["content"] and "line15" not in out["content"]
assert "start_line=15" in out["note"]
def test_read_file_truncates_long_lines(tmp_path):
(tmp_path / "wide.txt").write_text("x" * 2000 + "\n", encoding="utf-8")
read_file = file_tools(str(tmp_path))[0]
out = read_file(path="wide.txt")
assert "(line truncated)" in out["content"]
assert len(out["content"]) < 1000
def test_read_file_rejects_escape_and_non_files(tmp_path):
read_file = file_tools(str(tmp_path))[0]
assert "escapes" in read_file(path="../secret.txt")["error"]
assert "not a file" in read_file(path="missing.txt")["error"]
# -- web_fetch -----------------------------------------------------------------
def test_web_fetch_rejects_non_http():
web_fetch = make_web_fetch_tool()
assert "http" in web_fetch("file:///etc/passwd")["error"]
assert "http" in web_fetch("javascript:alert(1)")["error"]
def test_html_to_text_strips_scripts_and_tags():
html = "<html><head><style>x{}</style></head><body><h1>Hi</h1><script>bad()</script><p>Body text</p></body></html>"
text = _html_to_text(html)
assert "Hi" in text and "Body text" in text
assert "bad()" not in text and "x{}" not in text
# -- Code agent wiring ---------------------------------------------------------
def test_code_agent_has_grep_and_git_log_not_search_files(tmp_path):
from coworker.agents.base import AgentContext
from coworker.agents.code import code_agent
ctx = AgentContext(workspace=tmp_path, executor=None, todo=None)
names = {getattr(t, "__name__", "") for t in code_agent().build_tools(ctx)}
assert "grep" in names and "git_log" in names
assert "search_files" not in names # replaced by grep
assert "read_file_lines" not in names # folded into our windowed read_file
assert {"read_file", "write_file", "git_status", "git_diff"} <= names
def test_cowork_has_grep_not_search_files(tmp_path):
from coworker.agents.base import AgentContext
from coworker.agents.cowork import cowork_tool_factory
names = {
getattr(t, "__name__", "")
for t in cowork_tool_factory(AgentContext(workspace=tmp_path))
}
assert "grep" in names and "search_files" not in names
assert "git_log" not in names # git history isn't useful for Cowork