Some checks failed
Release & Docker / release (push) Has been cancelled
## 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>
115 lines
3.8 KiB
Python
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
|