Files
hackingtool/tests/test_tui_navigation.py
T
Hardik Zinzu 4a9c3d0075 feat: interactive TUI, self-update & native settings; apply community bug fixes
Interactive terminal UI (Textual, default on a TTY)
- Category browser with live tool counts; type to search EVERY tool across all
  categories; up/down move, Enter open, left/Esc back; `/` opens a command palette.
- Tool screen gates Run/Install/Uninstall behind a confirm, dropping to a real
  TTY so a tool can't crash the UI; Open-repo is http(s)-guarded.
- The engine is unchanged; `--classic` keeps the full prompt_toolkit REPL and the
  headless `--engagement` orchestrator is untouched.

Update awareness + self-update helper
- Cached-daily GitHub Releases check (offline-safe, opt-out via
  HACKINGTOOL_NO_UPDATE_CHECK / update_check config); shows an "update available"
  line on the main screen via a background worker (never blocks first render).
- `/update` detects the install method (pipx/pip/apt/docker/source) and shows the
  exact upgrade command to copy - it never runs it.

Native settings + config hygiene
- `/update`/`/config` run as in-TUI screens instead of suspending to
  prompt_toolkit (which raised "event loop already running" nested under Textual).
- Sponsor feed URL moved from user config to a fixed code constant (SPONSORS_URL);
  update_check is now a proper bool toggle.

Docker / README
- Docker image references aligned to hardikzinzu/hackingtool.
- README refreshed for the interactive UI; first screenshot replaced with the new UI.

Community-reported bug fixes (verified still present in current code)
- Data safety: atomic JSON/YAML writes (#773, #765); malformed config.json no
  longer crashes load/import (#775); UTF-8 for catalog/settings IO (#766);
  findings parsers skip non-object/typed records instead of aborting (#768);
  pipeline decodes invalid bytes and salvages timed-out evidence (#771, #770).
- AI layer: reject null/non-text provider responses (#778); auto-mode connection
  test honors the Ollama fallback (#779); settings stay responsive during the AI
  probe (#780); one findings snapshot per AI report (#772).
- CLI/REPL: `@nmap`/`@sqlmap` resolve canonically instead of fuzzy mis-hits (#767);
  bare `/` no longer crashes the REPL (#761); bad/incomplete args exit non-zero
  instead of opening the menu (#763); search/tag result #99 is reachable (#762);
  background pane creation failures are reported, not shown as "started" (#777);
  config/API-key save failures surface without closing settings (#776).
- Misc: .dockerignore keeps README.md for the build (#781); PortScan prompt markup
  fixed (#698); uninstall command lines use [warning] not [error] (#666, #663);
  untrusted-output fence can't be broken out of + script import fix (#757).
2026-10-15 19:01:16 +05:30

107 lines
4.3 KiB
Python

"""Regression tests for the TUI navigation/UX fixes (focus, left-back, hint line, stub feedback)."""
import asyncio
from textual.widgets import Input, OptionList, Static
from hackingtool.tui import data
from hackingtool.tui.app import HackingToolApp
from hackingtool.tui.screens.main import MainScreen
from hackingtool.tui.screens.category import CategoryScreen
async def _until(pilot, kind):
for _ in range(40):
await pilot.pause()
if isinstance(pilot.app.screen, kind):
return
raise AssertionError(f"{kind.__name__} never appeared")
def test_clicking_list_keeps_typing_on_prompt():
# issue 4: list is not focusable, so a click can't steal focus from the prompt
async def run():
async with HackingToolApp().run_test(size=(100, 30)) as pilot:
await _until(pilot, MainScreen)
ol = pilot.app.screen.query_one("#main-cats", OptionList)
assert ol.can_focus is False
await pilot.click("#main-cats")
await pilot.pause()
assert isinstance(pilot.app.focused, Input) # focus did NOT move to the list
await pilot.press("w")
await pilot.pause()
assert pilot.app.screen.query_one("#ht-prompt", Input).value == "w"
asyncio.run(run())
def test_left_arrow_goes_back_when_prompt_empty():
# issue 2/3: ← pops the screen when the prompt is empty
async def run():
async with HackingToolApp().run_test(size=(100, 30)) as pilot:
await _until(pilot, MainScreen)
await pilot.press("enter") # open a category
await _until(pilot, CategoryScreen)
await pilot.press("left")
await _until(pilot, MainScreen)
assert isinstance(pilot.app.screen, MainScreen)
asyncio.run(run())
def test_left_arrow_edits_text_when_prompt_has_value():
# ← must still move the cursor (not navigate) while typing
async def run():
async with HackingToolApp().run_test(size=(100, 30)) as pilot:
await _until(pilot, MainScreen)
await pilot.press("enter")
await _until(pilot, CategoryScreen)
inp = pilot.app.screen.query_one("#ht-prompt", Input)
inp.value = "ab"
inp.cursor_position = 2
await pilot.pause()
await pilot.press("left")
await pilot.pause()
assert isinstance(pilot.app.screen, CategoryScreen) # did not go back
assert inp.cursor_position == 1 # cursor moved instead
asyncio.run(run())
def test_left_arrow_does_not_pop_main_screen():
async def run():
async with HackingToolApp().run_test(size=(100, 30)) as pilot:
await _until(pilot, MainScreen)
await pilot.press("left")
await pilot.pause()
assert isinstance(pilot.app.screen, MainScreen) # nothing to pop
asyncio.run(run())
def test_hint_line_visible_and_context_aware():
# issue 1/3: a real hint row (not a truncated border subtitle); shows back only when poppable
async def run():
async with HackingToolApp().run_test(size=(100, 30)) as pilot:
await _until(pilot, MainScreen)
main_hint = pilot.app.screen.query_one("#ht-hint", Static)
assert "/ commands" in main_hint.content
assert "back" not in main_hint.content # root: no back
await pilot.press("enter")
await _until(pilot, CategoryScreen)
cat_hint = pilot.app.screen.query_one("#ht-hint", Static)
assert "← esc back" in cat_hint.content # category: back shown
asyncio.run(run())
def test_stub_command_notifies_instead_of_silent():
# issue 5: /report is a Phase-2 stub -> must surface feedback, not do nothing
async def run():
async with HackingToolApp().run_test(size=(100, 30)) as pilot:
await _until(pilot, MainScreen)
seen = {}
pilot.app.notify = lambda *a, **k: seen.setdefault("msg", (a, k))
inp = pilot.app.screen.query_one("#ht-prompt", Input)
inp.value = "/report"
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert "msg" in seen
assert "/report" in seen["msg"][0][0]
asyncio.run(run())