diff options
| author | blasty <blasty@local> | 2026-07-09 12:05:24 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 12:05:24 +0200 |
| commit | 8d67549eaa927c7ffc48c57f3ebc438c0fe7da09 (patch) | |
| tree | a310556afc303c444f184fdc8fca28843e19f54d /tests | |
| parent | keep idle workers alive: bump_idle_ttl + KeepAlive heartbeat (diff) | |
| download | ida-tui-8d67549eaa927c7ffc48c57f3ebc438c0fe7da09.tar.gz ida-tui-8d67549eaa927c7ffc48c57f3ebc438c0fe7da09.tar.xz ida-tui-8d67549eaa927c7ffc48c57f3ebc438c0fe7da09.zip | |
Phase 1 TUI: two-pane functions<->disasm, virtualized listing, nav backbone
- idatui/app.py: keyboard-first Textual app
* FunctionsPanel: lazy-streamed function list (DataTable) with glob filter
* DisasmView: line-VIRTUALIZED ScrollView -- paints from domain block cache,
background-fetches misses, prefetches neighbors. Proven: 52,120-insn func,
jump-to-bottom in 0.31s with only ~6 blocks (~1.5k lines) ever materialized.
* address-history nav stack (Enter follow / Esc back); goto by name or 0xADDR
* bump_idle_ttl + KeepAlive on startup so the session never idles out
* all MCP work on Textual worker threads; UI never blocks
- idatui/tui.py: launcher (python -m idatui.tui / console script 'idatui')
- tests/test_tui.py: headless Pilot test, 9/9 (boot, load 10k funcs, open,
scroll, bg-cache, goto-bottom, filter)
- pyproject: textual extra + idatui script entry
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_tui.py | 127 |
1 files changed, 127 insertions, 0 deletions
diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..752f72f --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Headless pilot test for the Phase-1 TUI (no real terminal needed). + + python3 tests/test_tui.py --db <session_id> + +Drives the app via Textual's Pilot: boots, loads the function list, opens a +function into the virtualized disasm view, scrolls it, and checks the cursor / +status update. Uses ~/ida-venv python (has textual). +""" +import asyncio +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from idatui.app import DisasmView, IdaTui # noqa: E402 +from textual.widgets import DataTable, Static # noqa: E402 + +PASS = FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +async def wait_until(pilot, pred, timeout=30.0, step=0.2): + waited = 0.0 + while waited < timeout: + if pred(): + return True + await pilot.pause(step) + waited += step + return False + + +async def run(db): + url = os.environ.get("IDA_MCP_URL", "http://127.0.0.1:8745/mcp") + app = IdaTui(url=url, db=db, keepalive=False) + async with app.run_test(size=(120, 40)) as pilot: + table = app.query_one("#func-table", DataTable) + status = app.query_one("#status", Static) + + loaded = await wait_until(pilot, lambda: table.row_count > 0) + check("function list populated", loaded, f"rows={table.row_count}") + + got_all = await wait_until( + pilot, lambda: "functions" in str(status.render()) and "…" not in str(status.render()), + timeout=30, + ) + check("function load completed (status settled)", got_all, str(status.render())) + nfuncs = table.row_count + print(f" loaded {nfuncs} functions") + + # Open the biggest function we can find (scan a sample of rows for size). + # Simpler: select the row whose Size column is largest among first N. + biggest_i, biggest_sz = 0, -1 + scan = min(nfuncs, 4000) + for i in range(scan): + row = table.get_row_at(i) + sz = int(str(row[2]), 16) + if sz > biggest_sz: + biggest_sz, biggest_i = sz, i + + table.move_cursor(row=biggest_i) + await pilot.pause(0.1) + table.focus() + await pilot.press("enter") + + view = app.query_one(DisasmView) + opened = await wait_until(pilot, lambda: view.total > 0, timeout=30) + check("disasm view opened with a total", opened, f"total={view.total}") + print(f" opened func with {view.total} instructions") + + # Wait for the first lines to be cached, then verify a rendered line. + cached = await wait_until( + pilot, lambda: view.model is not None and view.model.cached_line(0) is not None, + timeout=20, + ) + check("first instruction cached", cached) + + # Scroll down a page and confirm cursor advances + status shows an ea. + for _ in range(5): + await pilot.press("pagedown") + await pilot.pause(0.05) + moved = view.cursor > 0 + check("pagedown moved the cursor", moved, f"cursor={view.cursor}") + await wait_until(pilot, lambda: view.model.cached_line(view.cursor) is not None, 15) + check("cursor line eventually cached (bg fetch)", + view.model.cached_line(view.cursor) is not None) + check("status shows an address", "@ 0x" in str(status.render()), str(status.render())) + + # Jump to bottom of a (possibly huge) function; must not hang. + await pilot.press("end") + await pilot.pause(0.1) + check("goto-bottom lands near end", + view.cursor >= view.total - 1, f"cursor={view.cursor}/{view.total}") + + # Filter round-trip. + await pilot.press("slash") + await pilot.pause(0.1) + for ch in "sub_1*": + await pilot.press(ch if ch != "*" else "asterisk") + await pilot.press("enter") + filtered = await wait_until( + pilot, lambda: table.row_count > 0 and table.row_count < nfuncs, timeout=15 + ) + check("filter narrowed the list", filtered, f"rows={table.row_count} of {nfuncs}") + + +def main(argv): + db = None + it = iter(argv) + for a in it: + if a == "--db": + db = next(it) + asyncio.run(run(db)) + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) |
