mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
Regression coverage for the keystroke-latency fix: a URL token contains "/", so the bare-slash path heuristic used to return it as a path word and run os.listdir on every keystroke. Assert _extract_path_word rejects http/https/ssh scheme tokens, that ordinary paths (incl. a bare colon) are unaffected, and that the completer never touches the filesystem for a URL under the cursor.
227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
"""Tests for file path autocomplete in the CLI completer."""
|
|
|
|
import os
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from prompt_toolkit.document import Document
|
|
from prompt_toolkit.formatted_text import to_plain_text
|
|
|
|
from hermes_cli.commands import SlashCommandCompleter, _file_size_label
|
|
|
|
|
|
def _display_names(completions):
|
|
"""Extract plain-text display names from a list of Completion objects."""
|
|
return [to_plain_text(c.display) for c in completions]
|
|
|
|
|
|
def _display_metas(completions):
|
|
"""Extract plain-text display_meta from a list of Completion objects."""
|
|
return [to_plain_text(c.display_meta) if c.display_meta else "" for c in completions]
|
|
|
|
|
|
@pytest.fixture
|
|
def completer():
|
|
return SlashCommandCompleter()
|
|
|
|
|
|
class TestExtractPathWord:
|
|
def test_relative_path(self):
|
|
assert SlashCommandCompleter._extract_path_word("look at ./src/main.py") == "./src/main.py"
|
|
|
|
def test_home_path(self):
|
|
assert SlashCommandCompleter._extract_path_word("edit ~/docs/") == "~/docs/"
|
|
|
|
def test_absolute_path(self):
|
|
assert SlashCommandCompleter._extract_path_word("read /etc/hosts") == "/etc/hosts"
|
|
|
|
def test_parent_path(self):
|
|
assert SlashCommandCompleter._extract_path_word("check ../config.yaml") == "../config.yaml"
|
|
|
|
def test_path_with_slash_in_middle(self):
|
|
assert SlashCommandCompleter._extract_path_word("open src/utils/helpers.py") == "src/utils/helpers.py"
|
|
|
|
def test_plain_word_not_path(self):
|
|
assert SlashCommandCompleter._extract_path_word("hello world") is None
|
|
|
|
def test_empty_string(self):
|
|
assert SlashCommandCompleter._extract_path_word("") is None
|
|
|
|
def test_single_word_no_slash(self):
|
|
assert SlashCommandCompleter._extract_path_word("README.md") is None
|
|
|
|
def test_word_after_space(self):
|
|
assert SlashCommandCompleter._extract_path_word("fix the bug in ./tools/") == "./tools/"
|
|
|
|
def test_just_dot_slash(self):
|
|
assert SlashCommandCompleter._extract_path_word("./") == "./"
|
|
|
|
def test_just_tilde_slash(self):
|
|
assert SlashCommandCompleter._extract_path_word("~/") == "~/"
|
|
|
|
def test_url_is_not_treated_as_path(self):
|
|
# A URL contains "/" so the bare slash heuristic would otherwise return
|
|
# it as a path word, firing os.listdir("https:") on every keystroke.
|
|
assert SlashCommandCompleter._extract_path_word("see https://paste.rs/abc") is None
|
|
|
|
def test_http_url_is_not_treated_as_path(self):
|
|
assert SlashCommandCompleter._extract_path_word("ref http://example.com/x") is None
|
|
|
|
def test_scheme_alone_is_enough_to_reject(self):
|
|
# The "://" scheme separator is the signal, even before any path part
|
|
# has been typed.
|
|
assert SlashCommandCompleter._extract_path_word("ssh://host") is None
|
|
|
|
def test_path_word_with_colon_but_no_scheme_still_resolves(self):
|
|
# Only the "://" scheme separator should reject; a bare colon inside a
|
|
# real path token must not regress path detection.
|
|
assert (
|
|
SlashCommandCompleter._extract_path_word("open ./a:b/c.py") == "./a:b/c.py"
|
|
)
|
|
|
|
def test_ordinary_path_unaffected_by_url_guard(self):
|
|
assert (
|
|
SlashCommandCompleter._extract_path_word("edit src/pkg/mod.py")
|
|
== "src/pkg/mod.py"
|
|
)
|
|
|
|
|
|
class TestPathCompletions:
|
|
def test_lists_current_directory(self, tmp_path):
|
|
(tmp_path / "file_a.py").touch()
|
|
(tmp_path / "file_b.txt").touch()
|
|
(tmp_path / "subdir").mkdir()
|
|
|
|
old_cwd = os.getcwd()
|
|
os.chdir(tmp_path)
|
|
try:
|
|
completions = list(SlashCommandCompleter._path_completions("./"))
|
|
names = _display_names(completions)
|
|
assert "file_a.py" in names
|
|
assert "file_b.txt" in names
|
|
assert "subdir/" in names
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
def test_filters_by_prefix(self, tmp_path):
|
|
(tmp_path / "alpha.py").touch()
|
|
(tmp_path / "beta.py").touch()
|
|
(tmp_path / "alpha_test.py").touch()
|
|
|
|
completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/alpha"))
|
|
names = _display_names(completions)
|
|
assert "alpha.py" in names
|
|
assert "alpha_test.py" in names
|
|
assert "beta.py" not in names
|
|
|
|
def test_directories_have_trailing_slash(self, tmp_path):
|
|
(tmp_path / "mydir").mkdir()
|
|
(tmp_path / "myfile.txt").touch()
|
|
|
|
completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/"))
|
|
names = _display_names(completions)
|
|
metas = _display_metas(completions)
|
|
assert "mydir/" in names
|
|
idx = names.index("mydir/")
|
|
assert metas[idx] == "dir"
|
|
|
|
def test_home_expansion(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
(tmp_path / "testfile.md").touch()
|
|
|
|
completions = list(SlashCommandCompleter._path_completions("~/test"))
|
|
names = _display_names(completions)
|
|
assert "testfile.md" in names
|
|
|
|
def test_nonexistent_dir_returns_empty(self):
|
|
completions = list(SlashCommandCompleter._path_completions("/nonexistent_dir_xyz/"))
|
|
assert completions == []
|
|
|
|
def test_respects_limit(self, tmp_path):
|
|
for i in range(50):
|
|
(tmp_path / f"file_{i:03d}.txt").touch()
|
|
|
|
completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/", limit=10))
|
|
assert len(completions) == 10
|
|
|
|
def test_case_insensitive_prefix(self, tmp_path):
|
|
(tmp_path / "README.md").touch()
|
|
|
|
completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/read"))
|
|
names = _display_names(completions)
|
|
assert "README.md" in names
|
|
|
|
|
|
class TestIntegration:
|
|
"""Test the completer produces path completions via the prompt_toolkit API."""
|
|
|
|
def test_slash_commands_still_work(self, completer):
|
|
doc = Document("/hel", cursor_position=4)
|
|
event = MagicMock()
|
|
completions = list(completer.get_completions(doc, event))
|
|
names = _display_names(completions)
|
|
assert "/help" in names
|
|
|
|
def test_path_completion_triggers_on_dot_slash(self, completer, tmp_path):
|
|
(tmp_path / "test.py").touch()
|
|
old_cwd = os.getcwd()
|
|
os.chdir(tmp_path)
|
|
try:
|
|
doc = Document("edit ./te", cursor_position=9)
|
|
event = MagicMock()
|
|
completions = list(completer.get_completions(doc, event))
|
|
names = _display_names(completions)
|
|
assert "test.py" in names
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
def test_no_completion_for_plain_words(self, completer):
|
|
doc = Document("hello world", cursor_position=11)
|
|
event = MagicMock()
|
|
completions = list(completer.get_completions(doc, event))
|
|
assert completions == []
|
|
|
|
def test_url_does_not_touch_filesystem(self, completer, monkeypatch):
|
|
# Regression for laggy typing: a URL token contains "/", so before the
|
|
# scheme guard it reached _path_completions and called os.listdir on
|
|
# every keystroke. Assert no completions AND that the filesystem is
|
|
# never touched while a URL is under the cursor.
|
|
import hermes_cli.commands as commands_mod
|
|
|
|
def _fail(*_args, **_kwargs):
|
|
raise AssertionError("os.listdir must not run for a URL token")
|
|
|
|
monkeypatch.setattr(commands_mod.os, "listdir", _fail)
|
|
|
|
text = "open https://paste.rs/abc"
|
|
doc = Document(text, cursor_position=len(text))
|
|
event = MagicMock()
|
|
assert list(completer.get_completions(doc, event)) == []
|
|
|
|
def test_absolute_path_triggers_completion(self, completer):
|
|
doc = Document("check /etc/hos", cursor_position=14)
|
|
event = MagicMock()
|
|
completions = list(completer.get_completions(doc, event))
|
|
names = _display_names(completions)
|
|
# /etc/hosts should exist on Linux
|
|
assert any("host" in n.lower() for n in names)
|
|
|
|
|
|
class TestFileSizeLabel:
|
|
def test_bytes(self, tmp_path):
|
|
f = tmp_path / "small.txt"
|
|
f.write_text("hi")
|
|
assert _file_size_label(str(f)) == "2B"
|
|
|
|
def test_kilobytes(self, tmp_path):
|
|
f = tmp_path / "medium.txt"
|
|
f.write_bytes(b"x" * 2048)
|
|
assert _file_size_label(str(f)) == "2K"
|
|
|
|
def test_megabytes(self, tmp_path):
|
|
f = tmp_path / "large.bin"
|
|
f.write_bytes(b"x" * (2 * 1024 * 1024))
|
|
assert _file_size_label(str(f)) == "2.0M"
|
|
|
|
def test_nonexistent(self):
|
|
assert _file_size_label("/nonexistent_xyz") == ""
|