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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
|
#!/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 DecompView, DisasmView, FunctionsPanel, IdaTui # noqa: E402
from textual.widgets import DataTable, Input, 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. '/' is context-sensitive: it filters when the
# function table is focused (and searches when a code view is focused),
# so focus the table first.
table.focus()
await pilot.pause(0.1)
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__}")
# Decompiler toggle: open a function, Tab -> pseudocode, Shift+Tab -> back.
table.focus()
table.move_cursor(row=biggest_i)
await pilot.press("enter")
dis = app.query_one(DisasmView)
dec = app.query_one(DecompView)
await wait_until(pilot, lambda: dis.total > 0, timeout=20)
await pilot.press("tab")
pc = await wait_until(pilot, lambda: dec.display and dec.loaded_ea is not None, 25)
check("tab shows pseudocode", pc and app._active == "decomp",
f"active={app._active} disp={dec.display}")
check("pseudocode has many lines", dec.total > 20, f"lines={dec.total}")
# Highlighting: at least one styled (colored) segment across the body.
styled = any(
seg.style is not None and seg.style.color is not None
for strip in dec._strips[: min(dec.total, 200)]
for seg in strip
)
check("pseudocode is syntax-highlighted", styled)
await pilot.press("shift+tab")
await pilot.pause(0.2)
check("shift+tab returns to disassembly",
app._active == "disasm" and dis.display and not dec.display,
f"active={app._active}")
# Vim-style search in the disassembly view.
line0 = dis.model.cached_line(0)
raw = (line0.text.split() or ["push"])[0] if line0 else "push"
term = "".join(c for c in raw if c.isalnum())[:4] or "push"
await pilot.press("slash")
await pilot.pause(0.2)
for ch in term:
await pilot.press(ch)
await pilot.press("enter")
await wait_until(pilot, lambda: bool(dis._matches), timeout=25)
check("search finds matches", len(dis._matches) > 0, f"term={term!r}")
check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}")
check("match substring highlighted",
bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor)))
prev = dis.cursor
await pilot.press("slash")
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.pause(0.1)
check("'/' repeats to next match",
dis.cursor != prev and dis.cursor in dis._matches, f"cursor={dis.cursor}")
await pilot.press("question_mark")
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.pause(0.1)
check("'?' repeats to previous match", dis.cursor in dis._matches,
f"cursor={dis.cursor}")
# Incremental search + visible bar + Esc cancel.
si = app.query_one("#search", Input)
status = app.query_one("#status", Static)
await pilot.press("slash")
await pilot.pause(0.2)
check("search bar visible, status hidden (no overlap)",
si.display and not status.display, f"si={si.display} status={status.display}")
for ch in term:
await pilot.press(ch)
await pilot.pause(0.1)
check("matches highlight incrementally (before Enter)",
len(dis._matches) > 0 and si.value == term, f"val={si.value!r}")
await pilot.press("escape")
await pilot.pause(0.2)
check("Esc cancels: status restored, matches cleared",
status.display and not si.display and not dis._matches,
f"status={status.display} si={si.display} m={len(dis._matches)}")
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:]))
|