1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
|
#!/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, FunctionsPanel, 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}")
# Toggle the functions pane show/hide.
left = app.query_one("#left", FunctionsPanel)
await pilot.press("ctrl+b")
await pilot.pause(0.1)
check("ctrl+b hides functions pane + focuses disasm",
not left.display and isinstance(app.focused, DisasmView),
f"display={left.display} focus={type(app.focused).__name__}")
await pilot.press("ctrl+b")
await pilot.pause(0.1)
check("ctrl+b again restores pane + focuses table",
left.display and isinstance(app.focused, DataTable),
f"display={left.display} focus={type(app.focused).__name__}")
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:]))
|