diff options
Diffstat (limited to 'tests/test_scenarios.py')
| -rw-r--r-- | tests/test_scenarios.py | 160 |
1 files changed, 157 insertions, 3 deletions
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index ee2f77b..9d77680 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -20,12 +20,14 @@ import asyncio import fnmatch import os import re +import shutil import sys +import tempfile import traceback sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 - ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui, + ConfirmScreen, DecompView, FunctionsPanel, HexView, IdaTui, HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor, SymbolPalette, XrefsScreen, _str_display, _word_occurrences, ) @@ -369,6 +371,109 @@ async def s_load_options(c: Ctx): c.check("dialog output converts a base to paragraphs", load_args("arm", 0x8000000) == "-parm -b800000") + # Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with + # a modal up and nothing in a dialog could be tabbed to. That is why the load + # dialog's address field was unreachable — and it was broken in every other + # modal too. + from idatui.app import LoadOptionsScreen + app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234)) + await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10) + sc = app.screen + first = app.focused + await c.press("tab") + await c.pause(0.2) + c.check("Tab moves focus inside a modal instead of toggling the view", + app.focused is not first and isinstance(app.screen, LoadOptionsScreen), + f"focus={getattr(app.focused, 'id', None)}") + c.check("Tab in the load dialog lands on the address field", + getattr(app.focused, "id", None) == "load-base", + f"focus={getattr(app.focused, 'id', None)}") + await c.press("tab") + await c.pause(0.2) + c.check("Tab again returns to the processor filter", + getattr(app.focused, "id", None) == "pal-input", + f"focus={getattr(app.focused, 'id', None)}") + await c.press("escape") + await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10) + + +@scenario("asm_highlight") +async def s_asm_highlight(c: Ctx): + """Assembly is highlighted from IDA's OWN token tags. + + generate_disasm_line() emits \x01<tag>text\x02<tag>, and the tag says what + the text is — for every processor IDA supports. Nothing is lexed here, so + what this pins is that the tags survive the trip: parsed server-side, agreed + with the plain text, carried on the Head, and mapped to a style. + """ + from idatui.app import _S_SPAN + app = c.app + await c.open_biggest("listing") + lst = c.lst + ok = await c.wait(lambda: lst.model is not None and lst.total > 20, 25) + if not ok: + c.check("a listing to highlight", False, f"total={lst.total}") + return + lst.model.ensure(400) + rows = [lst.model.get(i) for i in range(min(lst.model.loaded(), 400))] + rows = [h for h in rows if h is not None] + code = [h for h in rows if h.kind == "code"] + c.check("code rows carry IDA's token spans", + code and sum(1 for h in code if h.spans) > len(code) * 0.9, + f"{sum(1 for h in code if h.spans)}/{len(code)} have spans") + + kinds = {k for h in rows for k, _ in (h.spans or ())} + # If a tag isn't mapped it renders as body text and nothing says why, so the + # ones that carry real meaning are worth asserting explicitly. + for want in ("insn", "reg", "punct"): + c.check(f"the palette sees {want} tokens", want in kinds, f"{sorted(kinds)}") + c.check("every span kind has a style", + all(k in _S_SPAN for k in kinds), f"unstyled: {sorted(kinds - set(_S_SPAN))}") + + # Spans must describe the SAME text the row shows, or the row renders + # different characters than search/width calculations think it has. + bad = [h for h in rows if h.spans + and "".join(t for _k, t in h.spans) != h.text] + c.check("spans reconstruct the row text exactly", not bad, + f"{[(hex(h.ea), h.text) for h in bad[:2]]}") + + # And the mnemonic must be the loudest thing on the line (the column you + # scan), not just any styled token. + mn = next((h for h in code if h.spans and h.spans[0][0] == "insn"), None) + c.check("the mnemonic is the first span", + mn is not None, f"{code[0].spans if code else None}") + + +@scenario("status_names_the_file") +async def s_status_names_the_file(c: Ctx): + """The status bar always says which file you're looking at. + + Obvious once several panes and a project switcher exist: with two idatui + windows open, or after switching binaries, "0x2490" alone doesn't say what + it belongs to. + """ + from textual.widgets import Static + app = c.app + await c.open_biggest("listing") + await c.pause(0.3) + name = os.path.basename(app._open_path) + status = str(app.query_one("#status", Static).render()) + c.check("the status bar names the open file", + status.startswith(f"[{name}]"), f"{status[:60]!r} (want [{name}])") + + # It must survive the messages that WRITE the status, not just the idle one. + c.lst.focus() + await c.press("down") + await c.pause(0.3) + status = str(app.query_one("#status", Static).render()) + c.check("and keeps naming it as you move", + status.startswith(f"[{name}]"), status[:60]) + + # The function-count message used to include the module name itself, which + # would now read "[echo] echo — 128 functions". + c.check("without saying the name twice", + status.count(name) == 1, status[:70]) + @scenario("command_palette") async def s_command_palette(c: Ctx): @@ -1835,7 +1940,7 @@ async def s_region_define(c: Ctx): c.check("listing spans the whole segment (more heads than one function)", seg is not None and c.lst.total > 1, f"total={c.lst.total} seg={seg}") - # 'p' on the entry head (re)creates the function and UPGRADES to DisasmView + # 'p' on the entry head (re)creates the function c.lst.focus() c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0 await c.pause(0.05) @@ -1924,9 +2029,16 @@ async def s_listing_view(c: Ctx): try: c.prog.undefine(dea, size=4) c.prog.bump_items() - # reopen the listing so the model reflects the new undefined run + # Reopen the listing so the model reflects the new undefined run, and + # wait for the model to be REPLACED. Waiting on index_of_ea(dea) >= 0 is + # no test at all: dea is in the OLD model too (it's the head we just + # undefined), so the predicate passes instantly and we then assert + # against pre-edit rows. It only ever passed because the swap happened to + # win the race, and it started failing the moment page loads got bigger. + stale = c.lst.model await c.goto_ui(hex(dea)) await c.wait(lambda: app._active == "listing" and c.lst.total > 0 + and c.lst.model is not stale and c.lst.model.index_of_ea(dea) >= 0, 25) ui = c.lst.model.index_of_ea(dea) c.check("undefining a data head yields an unknown run in the listing", @@ -2191,7 +2303,49 @@ async def s_func_banners(c: Ctx): # --------------------------------------------------------------------------- # # Runner # --------------------------------------------------------------------------- # +async def _build_pristine(binary, cache): + """Analyse ``binary`` once and keep the resulting database as a golden copy. + + Costs one full analysis, then every later run starts from it instead of + re-analysing. + """ + print(f" (building pristine database for {os.path.basename(binary)}\u2026)") + app = IdaTui(open_path=binary, keepalive=False) + async with app.run_test(size=(140, 44)) as pilot: + for _ in range(6000): + await pilot.pause(0.05) + if app._func_index is not None and app._func_index.complete: + break + app.program.client.call("idb_save", timeout=600.0) + db = binary + ".i64" + if os.path.exists(db): + shutil.copy2(db, cache) + + async def run(binary, only=None): + # The suite EDITS the database — it defines code, undefines items, renames + # and comments — and IDA saves those edits. Run that against the tracked + # target and every run inherits the last one's damage: decomp_follow_self + # started failing with no code change because an earlier scenario had + # undefined an instruction, and a position check looked flaky one run in + # three for the same reason. A suite whose result depends on its own history + # can't be trusted to accuse the code. + # + # So: work on a scratch copy, seeded from a golden database that nothing + # ever writes back to. + with tempfile.TemporaryDirectory(prefix="idatui-pilot-") as scratch: + target = os.path.join(scratch, os.path.basename(binary)) + shutil.copy2(binary, target) + cache = binary + ".pristine.i64" + if not (os.path.exists(cache) + and os.path.getmtime(cache) >= os.path.getmtime(binary)): + await _build_pristine(target, cache) + if os.path.exists(cache): + shutil.copy2(cache, target + ".i64") + await _run_on(target, only) + + +async def _run_on(binary, only=None): # Own idalib worker: opens the binary in-process over a unix socket. app = IdaTui(open_path=binary, keepalive=False) async with app.run_test(size=(140, 44)) as pilot: |
