Files
EvoScientist/tests/test_file_mentions.py
T
houren Antony d4f1fbd110 ci: add windows-latest to test matrix + fix 11 cross-platform test bugs (#271)
* ci: add windows-latest to test matrix + fix 11 cross-platform test bugs

The test workflow ran on ``ubuntu-latest`` only. Per the issue's
first bullet — the maintainer's explicit #1 priority — add
``windows-latest`` to the matrix so the manager and related
modules are exercised on Windows on every PR.

The matrix addition surfaces 18 pre-existing Windows-only test
failures. Without fixes the new leg would be 18+ reds from
day one and the matrix would just produce a wall of
``fail-fast`` noise. This PR fixes 11 of them; each fix is
a real (cross-platform) bug, not a Windows-specific hack —
most were already flagged by CodeRabbit on PR #236 but never
acted on. The remaining 4 failures need code refactors
(``os.killpg`` → ``psutil`` in ``background.py``,
``convert_virtual_paths_in_command`` Windows-aware quoting,
tilde expansion) that are documented as out-of-scope
follow-ups below.

## What changed

* ``.github/workflows/test.yml``
  - ``os: [ubuntu-latest, windows-latest]`` → 2 OS × 2 Python
    = 4 cells.
  - ``fail-fast: false`` so one bad cell doesn't cancel the
    rest while the Windows leg is being brought up. Removable
    in a future PR once the suite is fully green.

* ``tests/test_backends.py``
  - Hard-coded ``"python3"`` → ``{sys.executable}`` in 7
    test commands. Windows has no ``python3`` on PATH; using
    ``sys.executable`` is portable and matches what CodeRabbit
    flagged on PR #236.
  - Strict string comparisons → ``shlex.split`` round-trip in
    5 resolver tests. ``shlex.quote`` adds single quotes
    around backslash paths on Windows, which broke the
    direct ``==`` compare.
  - Cross-platform suffix checks in 2 path-resolution tests
    (``Path(resolved).parts[-2:]`` instead of
    ``str(resolved).endswith("src/main.py")``).
  - ``mkdir -p`` → ``sys.executable -c "import os;
    os.makedirs(...)"`` in the cwd-sanitization test.
  - ``skipif(sys.platform == "win32")`` on 3 e2e tests that
    hit the underlying ``shlex.quote`` + ``cmd.exe`` quoting
    bug (real, separate issue).

* ``tests/test_sessions.py``
  - ``test_uses_data_dir``: check ``.evoscientist`` in the
    long path form (via ``Path.resolve()``) rather than the
    short-path form ``get_db_path`` returns on Windows.

* ``tests/test_mcp_client.py``
  - ``endswith("python")`` → ``Path(result).stem.lower()`` so
    ``python.EXE`` matches on Windows.
  - ``endswith("npx")`` also accepts ``npx.cmd`` so the npm
    shim on Windows matches.

## Out of scope (follow-up issues to file)

* ``os.killpg`` doesn't exist on Windows
  (``EvoScientist/background.py:248``) — 3 background tests
  fail. Real fix is the same ``psutil`` walk pattern PR #200
  shipped in ``langgraph_dev/manager.py``.
* Tilde expansion in file mentions.
* Windows-aware shell quoting in
  ``convert_virtual_paths_in_command``.
* Path conventions (``~/.config/evoscientist/`` vs
  ``%APPDATA%\EvoScientist``) — needs design discussion +
  ``platformdirs`` migration.
* Cross-module audit of
  ``EvoScientist/tools/execute.py``,
  ``EvoScientist/ccproxy_manager.py``,
  ``EvoScientist/config/onboard.py``.

Closes #207 (step 1 only — CI matrix + the easy test
fixes; remaining bullets tracked separately).

* fix: cross-platform compatibility for Windows CI runners

- background.py: replace POSIX-only os.killpg/os.getpgid with
  cross-platform _kill_process_tree() helper. On Windows falls back
  to Popen.terminate()/Popen.kill() (TerminateProcess); on POSIX
  keeps existing os.killpg logic.

- test_backends.py: replace mkdir -p shell execution in
  test_literal_workspace_path_replaced with preprocessing-boundary
  assertion (patch LocalShellBackend.execute, capture command,
  assert workspace path was rewritten to ./). Avoids POSIX-only
  mkdir -p on Windows runners.

- test_file_mentions.py: monkeypatch USERPROFILE on Windows so
  ntpath.expanduser() resolves ~ to tmp_path even when HOME is
  unset on CI runners.

* fix(test): cross-platform sleep/true commands for Windows CI

Replace POSIX-only sleep/true with module-level helpers that use
ping -n / cmd /c on Windows. Also fix python3 -> sys.executable
in the non-timeout recovery test.

- test_background.py: 7 sleep/true fixes
- test_background_middleware.py: 6 sleep/true fixes
- test_backends.py: 4 sleep fixes + 1 python3 fix

2318 passed, 0 failed on Windows.

* fix(test): use shell-portable double quotes for python -c on Windows

cmd.exe does not treat single quotes as string delimiters, so
-c 'raise SystemExit(1)' was passed with literal quotes on Windows.
Switch to double quotes which work on both cmd.exe and POSIX sh.

* fix: use psutil for Windows process tree kill + avoid sys.executable under uv

- background.py: replace Popen.terminate()/kill() with psutil-based
  process tree walking on Windows. TerminateProcess does NOT cascade
  to grandchildren; psutil.Process.children(recursive=True) ensures
  the entire tree is signaled.

- test_backends.py: replace sys.executable with 'python' in sandbox
  execute() calls. Under uv, sys.executable is under the workspace
  and gets rewritten to ./ by prepare_sandbox_command, breaking
  Linux CI. The plain 'python' command resolves correctly in any
  activated venv.

* fix: broaden try/except in _kill_process_tree to cover proc.children()

If the process exits between Process(popen.pid) and children(recursive=True),
the children call raises an uncaught exception escaping stop(). Move it inside
the existing try/except block.

* fix: narrow exception to ProcessLookupError in POSIX _kill_process_tree

OSError is too broad — would silently swallow EPERM on SIGKILL, leaving
the process alive when we report it as stopped. Match original behavior
which only caught ProcessLookupError (process already gone).

* style: ruff format test_backends.py

* ci: trigger re-run for flaky prompt_toolkit test

* style: fix ruff check (import order + RUF005 unpacking)

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-06-10 15:43:18 +01:00

394 lines
16 KiB
Python

"""Tests for EvoScientist.cli.file_mentions module."""
from __future__ import annotations
import sys
from pathlib import Path
from EvoScientist.cli.file_mentions import (
complete_file_mention,
invalidate_file_cache,
parse_file_mentions,
resolve_file_mentions,
)
# ---------------------------------------------------------------------------
# parse_file_mentions
# ---------------------------------------------------------------------------
class TestParseFileMentions:
def test_single_mention(self, tmp_path: Path) -> None:
f = tmp_path / "paper.tex"
f.write_text("hello")
files, _ = parse_file_mentions(f"read @{f}", cwd=tmp_path)
assert files == [f.resolve()]
def test_relative_mention(self, tmp_path: Path) -> None:
f = tmp_path / "notes.md"
f.write_text("world")
files, _ = parse_file_mentions("check @notes.md", cwd=tmp_path)
assert files == [f.resolve()]
def test_multiple_mentions(self, tmp_path: Path) -> None:
a = tmp_path / "a.py"
b = tmp_path / "b.py"
a.write_text("a")
b.write_text("b")
files, _ = parse_file_mentions(f"diff @{a} @{b}", cwd=tmp_path)
assert set(files) == {a.resolve(), b.resolve()}
def test_missing_file_skipped(self, tmp_path: Path) -> None:
files, warnings = parse_file_mentions("@nonexistent.txt", cwd=tmp_path)
assert files == []
assert any("not found" in w for w in warnings)
def test_email_address_ignored(self, tmp_path: Path) -> None:
files, warnings = parse_file_mentions("send to user@example.com", cwd=tmp_path)
assert files == []
assert warnings == []
def test_directory_excluded(self, tmp_path: Path) -> None:
d = tmp_path / "subdir"
d.mkdir()
files, _ = parse_file_mentions(f"@{d}", cwd=tmp_path)
assert files == []
def test_bare_at_sign_ignored(self, tmp_path: Path) -> None:
files, warnings = parse_file_mentions("hello @ world", cwd=tmp_path)
assert files == []
assert warnings == []
def test_tilde_expansion(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
if sys.platform == "win32":
# ``ntpath.expanduser()`` falls back to ``USERPROFILE`` (and then
# ``HOMEDRIVE``+``HOMEPATH``) when ``HOME`` is absent. On some CI
# runners ``HOME`` is unset while ``USERPROFILE`` holds the real
# profile; patching both ensures ``~`` resolves to ``tmp_path``.
monkeypatch.setenv("USERPROFILE", str(tmp_path))
f = tmp_path / "file.txt"
f.write_text("x")
files, _ = parse_file_mentions("@~/file.txt", cwd=tmp_path)
assert files == [f.resolve()]
def test_duplicate_mention_deduplicated(self, tmp_path: Path) -> None:
"""Mentioning the same file twice returns it only once."""
f = tmp_path / "file.txt"
f.write_text("hello")
files, _ = parse_file_mentions(f"@{f} and again @{f}", cwd=tmp_path)
assert files == [f.resolve()]
def test_outside_workspace_warns(self, tmp_path: Path) -> None:
"""Files outside the workspace root trigger a warning but are still returned."""
outside = tmp_path.parent / "secret.txt"
outside.write_text("secret")
workspace = tmp_path / "workspace"
workspace.mkdir()
files, warnings = parse_file_mentions(f"@{outside}", cwd=workspace)
assert files == [outside.resolve()]
assert any("outside the workspace" in w for w in warnings)
def test_inside_workspace_no_warning(self, tmp_path: Path) -> None:
"""Files inside the workspace do NOT produce an outside-workspace warning."""
f = tmp_path / "safe.txt"
f.write_text("data")
_, warnings = parse_file_mentions(f"@{f}", cwd=tmp_path)
assert not any("outside the workspace" in w for w in warnings)
def test_escaped_space_in_path(self, tmp_path: Path) -> None:
"""Backslash-escaped spaces remain supported (existing behavior)."""
f = tmp_path / "my paper.pdf"
f.write_text("x")
files, _ = parse_file_mentions("see @my\\ paper.pdf", cwd=tmp_path)
assert files == [f.resolve()]
def test_double_quoted_path_with_spaces(self, tmp_path: Path) -> None:
f = tmp_path / "PREPING_ Building Agent Memory.pdf"
f.write_text("x")
files, _ = parse_file_mentions(
'read @"PREPING_ Building Agent Memory.pdf"', cwd=tmp_path
)
assert files == [f.resolve()]
def test_single_quoted_path_with_spaces(self, tmp_path: Path) -> None:
f = tmp_path / "notes with spaces.md"
f.write_text("x")
files, _ = parse_file_mentions(
"open @'notes with spaces.md' please", cwd=tmp_path
)
assert files == [f.resolve()]
def test_greedy_extends_across_unescaped_spaces(self, tmp_path: Path) -> None:
"""Bare ``@path with spaces`` should still resolve when the file exists."""
f = tmp_path / "PREPING_ Building Agent Memory without Tasks.pdf"
f.write_text("x")
files, warnings = parse_file_mentions(
"@PREPING_ Building Agent Memory without Tasks.pdf 阅读本文",
cwd=tmp_path,
)
assert files == [f.resolve()]
assert warnings == []
def test_greedy_extension_stops_at_next_mention(self, tmp_path: Path) -> None:
"""Greedy expansion must not gobble across another ``@mention``."""
a = tmp_path / "a.txt"
b = tmp_path / "b.txt"
a.write_text("a")
b.write_text("b")
# ``@a.txt some prose @b.txt`` — both files should be picked up
# independently; the first must not swallow ``@b.txt``.
files, _ = parse_file_mentions("@a.txt some prose @b.txt", cwd=tmp_path)
assert set(files) == {a.resolve(), b.resolve()}
def test_greedy_extension_stops_at_newline(self, tmp_path: Path) -> None:
"""Greedy expansion must not cross a newline."""
f = tmp_path / "alpha.txt"
f.write_text("x")
files, warnings = parse_file_mentions(
"@missing path\nsome other line", cwd=tmp_path
)
# Nothing resolves and we should not consume across the newline.
assert files == []
assert any("not found" in w for w in warnings)
def test_greedy_extension_only_when_bare_fails(self, tmp_path: Path) -> None:
"""If bare ``@token`` resolves, do not greedily extend it."""
f = tmp_path / "a.txt"
f.write_text("x")
# ``@a.txt and more text`` — should pick up just ``a.txt``.
files, _ = parse_file_mentions("@a.txt and more text", cwd=tmp_path)
assert files == [f.resolve()]
def test_greedy_strips_trailing_punctuation(self, tmp_path: Path) -> None:
f = tmp_path / "my notes.md"
f.write_text("x")
files, _ = parse_file_mentions("see @my notes.md, then continue", cwd=tmp_path)
assert files == [f.resolve()]
def test_quoted_missing_does_not_extend(self, tmp_path: Path) -> None:
"""Quoted form is explicit — no greedy expansion when it fails."""
f = tmp_path / "a b c.txt"
f.write_text("x")
files, warnings = parse_file_mentions(
'@"missing file.txt" but a b c.txt exists', cwd=tmp_path
)
assert files == []
assert any("not found" in w for w in warnings)
# ---------------------------------------------------------------------------
# resolve_file_mentions
# ---------------------------------------------------------------------------
class TestResolveFileMentions:
def test_no_mentions_returns_original(self, tmp_path: Path) -> None:
text = "just a normal message"
original, final, warnings = resolve_file_mentions(text, str(tmp_path))
assert original == text
assert final == text
assert warnings == []
def test_mention_appends_content(self, tmp_path: Path) -> None:
f = tmp_path / "README.md"
f.write_text("# Hello")
text = f"summarise @{f}"
original, final, _ = resolve_file_mentions(text, str(tmp_path))
assert original == text
assert "## Referenced Files" in final
assert "README.md" in final
assert "# Hello" in final
def test_large_file_reference_only(self, tmp_path: Path) -> None:
f = tmp_path / "big.txt"
# Write more than 256 KB — use .txt so it's not caught by binary check
f.write_bytes(b"x" * (260 * 1024))
text = f"read @{f}"
_, final, _ = resolve_file_mentions(text, str(tmp_path))
assert "too large to embed" in final
assert "read_file" in final
def test_binary_file_reference_only(self, tmp_path: Path) -> None:
f = tmp_path / "image.png"
f.write_bytes(b"\x89PNG\r\n" + b"\x00" * 100)
text = f"look at @{f}"
_, final, _ = resolve_file_mentions(text, str(tmp_path))
assert "binary file" in final
assert "read_file" in final
def test_multiple_files_all_embedded(self, tmp_path: Path) -> None:
a = tmp_path / "a.txt"
b = tmp_path / "b.txt"
a.write_text("AAA")
b.write_text("BBB")
_, final, _ = resolve_file_mentions(f"@{a} and @{b}", str(tmp_path))
assert "AAA" in final
assert "BBB" in final
def test_original_text_always_first(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("content")
text = f"look at @{f} please"
_, final, _ = resolve_file_mentions(text, str(tmp_path))
assert final.startswith(text)
def test_none_workspace_uses_cwd(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
f = tmp_path / "local.txt"
f.write_text("local")
_, final, _ = resolve_file_mentions("@local.txt", None)
assert "local" in final
def test_warnings_returned_not_printed(self, tmp_path: Path) -> None:
"""Warnings are returned in the third element, not printed to stdout."""
outside = tmp_path.parent / "outside.txt"
outside.write_text("sensitive")
workspace = tmp_path / "workspace"
workspace.mkdir()
_, _, warnings = resolve_file_mentions(f"@{outside}", str(workspace))
assert any("outside the workspace" in w for w in warnings)
# ---------------------------------------------------------------------------
# complete_file_mention
# ---------------------------------------------------------------------------
class TestCompleteFileMention:
def setup_method(self) -> None:
# Ensure cache is clean between tests
invalidate_file_cache()
def test_no_at_sign_returns_empty(self, tmp_path: Path) -> None:
result = complete_file_mention("hello world", str(tmp_path))
assert result == []
def test_at_sign_alone_lists_files(self, tmp_path: Path) -> None:
(tmp_path / "paper.tex").write_text("")
(tmp_path / "code.py").write_text("")
result = complete_file_mention("read @", str(tmp_path))
paths = [p for p, _ in result]
names = [Path(p.lstrip("@")).name for p in paths]
assert "paper.tex" in names
assert "code.py" in names
def test_partial_prefix_filters(self, tmp_path: Path) -> None:
(tmp_path / "paper.tex").write_text("")
(tmp_path / "data.csv").write_text("")
result = complete_file_mention("@pa", str(tmp_path))
paths = [p for p, _ in result]
assert any("paper" in p for p in paths)
assert not any("data" in p for p in paths)
def test_directory_has_trailing_slash(self, tmp_path: Path) -> None:
(tmp_path / "workspace").mkdir()
result = complete_file_mention("@work", str(tmp_path))
paths = [p for p, _ in result]
assert any(p.endswith("/") for p in paths)
def test_hidden_files_excluded(self, tmp_path: Path) -> None:
(tmp_path / ".hidden").write_text("")
(tmp_path / "visible.txt").write_text("")
result = complete_file_mention("@", str(tmp_path))
paths = [p for p, _ in result]
assert not any(".hidden" in p for p in paths)
assert any("visible" in p for p in paths)
def test_mid_text_at_completes(self, tmp_path: Path) -> None:
(tmp_path / "intro.tex").write_text("")
result = complete_file_mention("please look at @intro", str(tmp_path))
paths = [p for p, _ in result]
assert any("intro.tex" in p for p in paths)
def test_no_at_in_text_returns_empty(self, tmp_path: Path) -> None:
result = complete_file_mention("no mention here", str(tmp_path))
assert result == []
def test_returns_tuples(self, tmp_path: Path) -> None:
(tmp_path / "report.md").write_text("")
result = complete_file_mention("@rep", str(tmp_path))
assert result # at least one result
assert isinstance(result[0], tuple)
assert len(result[0]) == 2
def test_type_hint_extension(self, tmp_path: Path) -> None:
(tmp_path / "script.py").write_text("")
(tmp_path / "notes.md").write_text("")
(tmp_path / "data.csv").write_text("")
result = complete_file_mention("@", str(tmp_path))
hint_map = {Path(p.lstrip("@")).name: h for p, h in result}
assert hint_map.get("script.py") == "py"
assert hint_map.get("notes.md") == "md"
assert hint_map.get("data.csv") == "csv"
def test_type_hint_no_extension(self, tmp_path: Path) -> None:
(tmp_path / "Makefile").write_text("")
result = complete_file_mention("@Make", str(tmp_path))
hint_map = {Path(p.lstrip("@")).name: h for p, h in result}
assert hint_map.get("Makefile") == "file"
def test_directory_type_hint(self, tmp_path: Path) -> None:
(tmp_path / "subdir").mkdir()
result = complete_file_mention("@sub", str(tmp_path))
pairs = dict(result)
# The dir entry ends with /
dir_entries = [(p, h) for p, h in pairs.items() if p.endswith("/")]
assert dir_entries
assert dir_entries[0][1] == "dir"
def test_fuzzy_match_partial_query(self, tmp_path: Path) -> None:
"""'resu' should fuzzy-match 'results.json'."""
(tmp_path / "results.json").write_text("")
(tmp_path / "readme.txt").write_text("")
result = complete_file_mention("@resu", str(tmp_path))
paths = [p for p, _ in result]
assert any("results.json" in p for p in paths)
def test_fuzzy_deep_file(self, tmp_path: Path) -> None:
"""Files nested 2+ levels deep are discovered by fuzzy search."""
nested = tmp_path / "src" / "models"
nested.mkdir(parents=True)
(nested / "base.py").write_text("")
result = complete_file_mention("@base", str(tmp_path))
paths = [p for p, _ in result]
assert any("base.py" in p for p in paths)
def test_cache_reuse(self, tmp_path: Path) -> None:
"""Second call with same workspace does not rescan."""
(tmp_path / "file1.txt").write_text("")
ws = str(tmp_path)
# Warm cache
complete_file_mention("@", ws)
# Add a new file — should NOT appear until cache is invalidated
(tmp_path / "new_file.txt").write_text("")
result = complete_file_mention("@new", ws)
paths = [p for p, _ in result]
assert not any("new_file" in p for p in paths)
def test_invalidate_cache_triggers_rescan(self, tmp_path: Path) -> None:
"""After invalidation, new files appear in results."""
(tmp_path / "file1.txt").write_text("")
ws = str(tmp_path)
complete_file_mention("@", ws)
# Add new file then invalidate
(tmp_path / "new_file.txt").write_text("")
invalidate_file_cache(ws)
result = complete_file_mention("@new", ws)
paths = [p for p, _ in result]
assert any("new_file" in p for p in paths)
def test_completion_quotes_paths_with_spaces(self, tmp_path: Path) -> None:
"""Files whose relative path contains a space come back as ``@"..."``."""
(tmp_path / "my notes.md").write_text("")
result = complete_file_mention("@my", str(tmp_path))
paths = [p for p, _ in result]
assert any(p == '@"my notes.md"' for p in paths)
def test_completion_inside_quoted_partial(self, tmp_path: Path) -> None:
"""Quoted partial allows the user to keep typing past the space."""
(tmp_path / "PREPING_ Building.pdf").write_text("")
result = complete_file_mention('@"PREPING_ Bui', str(tmp_path))
paths = [p for p, _ in result]
assert any("PREPING_ Building.pdf" in p and p.endswith('"') for p in paths)