Files
hermes-webui/tests/test_issue3433_composer_path_completion.py
nesquena-hermes 15e654d468
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.251 — Release HS (stage-q23) (#3527)
## Release v0.51.251 — Release HS (stage-q23)

UX-verified live (path dropdown opens on `~/`).

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3433 | @puneetdixit200 | **Composer `~/` path autocomplete** (TUI parity). Typing a `~/` token in the composer opens a home-directory path-suggestion dropdown. Reuses the existing slash-command dropdown (positioning + keyboard nav) and the trusted `/api/workspaces/suggest` endpoint; replaces only the matched token on selection (surrounding text preserved). Slash-command autocomplete still takes precedence for `/`-prefixed input. |

### Gate
- Full pytest suite: **7568 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · live-verified the dropdown opens on `~/`
- Codex (regression): **SAFE TO SHIP** — slash-precedence preserved, `~/../../etc` → no suggestions (path-escape blocked via root-confined endpoint), bounds-clamped token replacement, esc-escaped, URLSearchParams-encoded

Co-authored-by: puneetdixit200 <puneetdixit200@users.noreply.github.com>
2026-06-03 21:12:23 -07:00

115 lines
3.8 KiB
Python

"""Regression tests for #3433: chat composer path completion for ~/ tokens."""
import json
import pathlib
import shutil
import subprocess
import textwrap
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent
COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _run_commands_js(script_body: str) -> dict:
script = textwrap.dedent(
f"""
const vm = require('vm');
const ctx = {{
console,
URL,
URLSearchParams,
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
t: (key) => key,
api: async (path) => {{
const url = new URL('http://hermes.local' + path);
if (url.pathname !== '/api/workspaces/suggest') {{
throw new Error('unexpected api path: ' + path);
}}
const prefix = url.searchParams.get('prefix');
return {{
suggestions: prefix === '~/' ? ['~', '~/Documents', '~/Projects'] : []
}};
}}
}};
vm.createContext(ctx);
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
(async () => {{
const result = await vm.runInContext(`(async () => {{ {script_body} }})()`, ctx);
process.stdout.write(JSON.stringify(result));
}})().catch(err => {{
console.error(err && err.stack || err);
process.exit(1);
}});
"""
)
proc = subprocess.run(
[NODE, "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(proc.stdout)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_composer_path_token_matches_tilde_path_inside_message():
result = _run_commands_js(
"""
return {
token: _findComposerPathToken('please inspect ~/Doc', 20),
slash: _findComposerPathToken('/model gpt', 10),
bareTilde: _findComposerPathToken('please inspect ~', 16)
};
"""
)
assert result["token"] == {"start": 15, "end": 20, "prefix": "~/Doc"}
assert result["slash"] is None
assert result["bareTilde"] is None
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_composer_path_autocomplete_uses_workspace_suggest_endpoint():
result = _run_commands_js(
"""
const matches = await getComposerPathAutocompleteMatches('please inspect ~/', 17);
return {
count: matches.length,
first: matches[0],
second: matches[1]
};
"""
)
assert result["count"] == 2
assert result["first"]["source"] == "path"
assert result["first"]["value"] == "~/Documents"
assert result["first"]["tokenStart"] == 15
assert result["first"]["tokenEnd"] == 17
assert result["second"]["value"] == "~/Projects"
def test_composer_input_uses_path_autocomplete_after_slash_branch():
assert "const cursor=$('msg').selectionStart;" in BOOT_JS
assert "getComposerPathAutocompleteMatches(text,cursor).then(matches=>" in BOOT_JS
assert "ta.value!==text||ta.selectionStart!==cursor" in BOOT_JS
def test_dropdown_selection_replaces_only_path_token():
assert "const isPath=c.source==='path';" in COMMANDS_JS
assert "tokenStart:token.start" in COMMANDS_JS
assert "tokenEnd:token.end" in COMMANDS_JS
assert "current.slice(0,safeStart)+nextPath+current.slice(safeEnd)" in COMMANDS_JS
assert "ta.setSelectionRange(pos,pos);" in COMMANDS_JS
assert "ta.dispatchEvent(new Event('input',{bubbles:true}));" in COMMANDS_JS
def test_path_suggestions_have_distinct_dropdown_style():
assert ".cmd-item-path-value" in STYLE_CSS