diff options
| author | blasty <blasty@local> | 2026-07-10 14:53:59 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-10 14:53:59 +0200 |
| commit | ab9dad156891a1f44eca17a9ea6d979b56564c3e (patch) | |
| tree | 6ccc6ec51d233f416634731552c321973037beb0 /tests | |
| parent | tests: isolated scenario suite (parity with the monolith, fast --only) (diff) | |
| download | ida-tui-ab9dad156891a1f44eca17a9ea6d979b56564c3e.tar.gz ida-tui-ab9dad156891a1f44eca17a9ea6d979b56564c3e.tar.xz ida-tui-ab9dad156891a1f44eca17a9ea6d979b56564c3e.zip | |
tests: retire the monolithic test_tui.py (superseded by test_scenarios)
The scenario suite now covers every behavior the monolith did (all check
names accounted for, modulo renames), so drop the old single-session
runner.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_tui.py | 1236 |
1 files changed, 0 insertions, 1236 deletions
diff --git a/tests/test_tui.py b/tests/test_tui.py deleted file mode 100644 index 6d77fc2..0000000 --- a/tests/test_tui.py +++ /dev/null @@ -1,1236 +0,0 @@ -#!/usr/bin/env python3 -"""Headless pilot test for the Phase-1 TUI (no real terminal needed). - - python3 tests/test_tui.py --db <session_id> - python3 tests/test_tui.py --db <session_id> --stop-after "search finds" - -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). - ---stop-after <substr> ends the run once a check whose name contains <substr> has -been evaluated, skipping the (often expensive) tail -- handy for fast iteration -on a single feature. The default (no flag) runs the whole suite. -""" -import asyncio -import os -import re -import sys - -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, - StructEditor, SymbolPalette, XrefsScreen, -) -from textual.widgets import DataTable, Input, OptionList, Static, TextArea # noqa: E402 -from rich.text import Text # noqa: E402 - -PASS = FAIL = 0 -STOP_AFTER = None # --stop-after <substr>: end the run once a matching check ran - - -class _StopSuite(Exception): - """Unwind cleanly once the requested --stop-after check has been evaluated.""" - - -def check(name, cond, detail=""): - global PASS, FAIL - if cond: - PASS += 1 - print(f" ok {name}") - else: - FAIL += 1 - print(f" FAIL {name} {detail}") - if STOP_AFTER and STOP_AFTER in name: - raise _StopSuite - - -async def wait_until(pilot, pred, timeout=30.0, step=0.02): - # Poll finely so a condition that resolves quickly (the common case on a warm - # session) is detected promptly instead of up to `step` late across the ~50 - # call sites; the timeout ceiling is unchanged. - 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}") - # The names pane is an overlay now (Ctrl+N) and starts hidden; reveal the - # classic docked pane (Ctrl+B) for the table-driven checks below. - left = app.query_one("#left") - check("names pane starts hidden (overlay-first)", not left.display, - f"display={left.display}") - await pilot.press("ctrl+b") - await wait_until(pilot, lambda: left.display, timeout=5) - check("function pane width is capped (doesn't eat the screen)", - left.size.width <= 44, f"width={left.size.width}") - - 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") - - # Symbol palette (Ctrl+N): fuzzy find + open, overlay-style. - await pilot.press("ctrl+n") - pal_open = await wait_until(pilot, lambda: isinstance(app.screen, SymbolPalette), 10) - check("Ctrl+N opens the symbol palette", pal_open, - f"screen={type(app.screen).__name__}") - if pal_open: - pal = app.screen - pinp = pal.query_one(Input) - pinp.value = "main" - await wait_until(pilot, lambda: pal._results and pal._results[0].name == "main", 10) - check("palette fuzzy-finds (top result matches the query)", - bool(pal._results) and pal._results[0].name == "main", - f"top={pal._results[0].name if pal._results else None}") - pinp.value = "eror" # scattered subsequence of 'error' - await wait_until(pilot, lambda: any(f.name == "error" for f in pal._results), 10) - check("palette matches a fuzzy subsequence", - any(f.name == "error" for f in pal._results), - f"results={[f.name for f in pal._results[:4]]}") - pinp.value = "main" - await wait_until(pilot, lambda: pal._results and pal._results[0].name == "main", 10) - want = pal._results[0].addr - await pilot.press("enter") - await wait_until(pilot, lambda: not isinstance(app.screen, SymbolPalette), 10) - await wait_until(pilot, lambda: app._cur and app._cur.ea == want, 20) - check("selecting a palette entry opens that function", - bool(app._cur) and app._cur.ea == want, - f"cur={app._cur.ea if app._cur else None}") - await pilot.press("ctrl+n") - await wait_until(pilot, lambda: isinstance(app.screen, SymbolPalette), 10) - await pilot.press("escape") - await wait_until(pilot, lambda: not isinstance(app.screen, SymbolPalette), 10) - check("Esc closes the palette", not isinstance(app.screen, SymbolPalette)) - - # Decompile-failure fallback: a function that can't be decompiled opens - # in disassembly (not an error panel), and the pseudocode preference is - # kept so the next decompilable function still opens as pseudocode. - # (Import/PLT stubs at high addresses fail fast; scan from the top.) - d_view = app.query_one(DisasmView) - c_view = app.query_one(DecompView) - failing = next((f for f in reversed(app.program.functions().all_loaded()) - if app.program.decompile(f.addr).failed), None) - if failing is not None and app._pref == "decomp": - app._goto(hex(failing.addr)) - await wait_until(pilot, lambda: app._cur and app._cur.ea == failing.addr, 20) - await wait_until(pilot, lambda: app._active == "disasm", 20) - check("decompile failure falls back to disassembly (preference kept)", - app._active == "disasm" and d_view.display and not c_view.display - and app._pref == "decomp", - f"active={app._active} pref={app._pref} " - f"disp(dis={d_view.display},dec={c_view.display})") - app._goto("main") - await wait_until(pilot, lambda: app._cur and app._cur.name == "main", 20) - await wait_until(pilot, lambda: app._active == "decomp" and c_view.display, 25) - check("preference preserved: next function opens as pseudocode", - app._active == "decomp" and c_view.display, f"active={app._active}") - else: - check("found a decompile-failing function", failing is not None, - f"pref={app._pref}") - - # Struct editor (Ctrl+T): list / view / create / update / delete. - await pilot.press("ctrl+t") - se_open = await wait_until(pilot, lambda: isinstance(app.screen, StructEditor), 10) - check("Ctrl+T opens the struct editor", se_open, - f"screen={type(app.screen).__name__}") - if se_open: - se = app.screen - await wait_until(pilot, lambda: bool(se._structs), 15) - check("struct editor lists existing structs", len(se._structs) > 0, - f"n={len(se._structs)}") - ta = se.query_one(TextArea) - tname = next((s.name for s in se._structs if s.name == "timespec"), - se._structs[0].name) - idx = next(i for i, s in enumerate(se._structs) if s.name == tname) - se.query_one(OptionList).highlighted = idx - se.on_option_list_option_selected(type("E", (), {"option_index": idx})()) - await wait_until(pilot, lambda: tname in ta.text and "{" in ta.text, 15) - check("selecting a struct shows its C definition", - tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}") - # Ctrl+Y copies the whole definition to the clipboard (OSC 52 + tmux). - app._clipboard = "" - se.query_one(TextArea).focus() - await pilot.press("ctrl+y") - await wait_until(pilot, lambda: app._clipboard == ta.text, 10) - check("Ctrl+Y copies the struct definition to the clipboard", - bool(app._clipboard) and app._clipboard == ta.text, - f"clip_len={len(app._clipboard)}") - # create (fixed name so re-runs update in place -> no churn) - sname = "TuiEdTest" - await pilot.press("ctrl+n") - await pilot.pause(0.05) - ta.text = f"struct {sname} {{ int a; char b[8]; }};" - await pilot.press("ctrl+s") - await wait_until(pilot, lambda: any(s.name == sname for s in se._structs), 15) - check("Ctrl+S declares a new struct", - any(s.name == sname for s in se._structs), "not created") - # save auto-formats the (one-line) definition to canonical layout - await wait_until(pilot, lambda: "\n" in ta.text, 10) - check("save auto-formats the definition in the editor", - ta.text.count("\n") >= 3 and f"struct {sname}" in ta.text - and not se._is_dirty(), f"text={ta.text[:50]!r}") - # update it (add a field -> member count grows) - idx = next(i for i, s in enumerate(se._structs) if s.name == sname) - se.on_option_list_option_selected(type("E", (), {"option_index": idx})()) - await wait_until(pilot, lambda: sname in ta.text, 10) - ta.text = f"struct {sname} {{ int a; char b[8]; long c; }};" - await pilot.press("ctrl+s") - await wait_until( - pilot, - lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, - 15) - check("Ctrl+S updates an existing struct in place", - next((s.members for s in se._structs if s.name == sname), 0) == 3, - "member count not 3") - # A save IDA's C parser rejects (the reserved field name '__unused') - # fails loudly and non-destructively, naming the offending field. - ta.text = f"struct {sname} {{ int a; char b[8]; long c; int __unused; }};" - await pilot.press("ctrl+s") - await wait_until( - pilot, lambda: "save failed" in str(se.query_one("#se-status").render()), 15) - st = str(se.query_one("#se-status").render()) - check("a rejected save fails loudly, naming the reserved field", - "save failed" in st and "__unused" in st, f"status={st!r}") - check("a rejected save leaves the struct unchanged", - next((s.members for s in se._structs if s.name == sname), 0) == 3, - "members changed") - check("a rejected save keeps your edited text", "__unused" in ta.text) - # Unsaved edits are guarded when switching structs. - other = next(i for i, s in enumerate(se._structs) if s.name != sname) - se.on_option_list_option_selected(type("E", (), {"option_index": other})()) - guard = await wait_until(pilot, lambda: isinstance(app.screen, ConfirmScreen), 10) - check("unsaved edits prompt before switching structs", guard, - f"screen={type(app.screen).__name__}") - await pilot.press("enter") # confirm discard -> loads the other (clean) - await wait_until( - pilot, lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15) - # delete: 'd' asks to confirm, Enter deletes (server del_type). If the - # server lacks del_type, the status reports it instead. - idx = next(i for i, s in enumerate(se._structs) if s.name == sname) - se.query_one(OptionList).focus() - se.query_one(OptionList).highlighted = idx - await pilot.press("d") - confirmed = await wait_until( - pilot, lambda: isinstance(app.screen, ConfirmScreen), 10) - check("delete asks for confirmation", confirmed, - f"screen={type(app.screen).__name__}") - await pilot.press("enter") - await wait_until(pilot, lambda: isinstance(app.screen, StructEditor), 10) - await wait_until( - pilot, - lambda: not any(s.name == sname for s in se._structs) - or "del_type" in str(se.query_one("#se-status").render()), 15) - st = str(se.query_one("#se-status").render()) - gone = not any(s.name == sname for s in se._structs) - check("confirming delete removes the struct (or reports missing tool)", - gone or "del_type" in st, f"gone={gone} status={st!r}") - se.query_one(OptionList).focus() - await pilot.press("escape") - await wait_until(pilot, lambda: not isinstance(app.screen, StructEditor), 10) - check("Esc closes the struct editor", not isinstance(app.screen, StructEditor)) - - # 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.05) - 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") - - # Pseudocode is the DEFAULT view: the first function opens into it. - dec0 = app.query_one(DecompView) - pc = await wait_until( - pilot, lambda: app._active == "decomp" and dec0.display - and dec0.loaded_ea is not None, 25) - check("opening a function shows pseudocode by default", pc, - f"active={app._active} disp={dec0.display}") - # Switch to disassembly for the disasm scrolling checks below. - await pilot.press("tab") - await wait_until(pilot, lambda: app._active == "disasm", 10) - view.focus() - - # 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.025) - 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())) - - # Ctrl+Y copies the current code line to the clipboard. - view.focus() - cur_line = view._line_plain(view.cursor) - app._clipboard = "" - await pilot.press("ctrl+y") - await wait_until(pilot, lambda: app._clipboard == cur_line, 10) - check("Ctrl+Y copies the current code line to the clipboard", - bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}") - - # Jump to bottom of a (possibly huge) function; must not hang. - await pilot.press("end") - await pilot.pause(0.05) - check("goto-bottom lands near end", - view.cursor >= view.total - 1, f"cursor={view.cursor}/{view.total}") - - # Hex view: backslash shows the raw image bytes synced to the code cursor. - view.focus() - code_ea = view._cursor_ea() - await pilot.press("backslash") - await wait_until(pilot, lambda: app._active == "hex", 10) - hx = app.query_one(HexView) - await wait_until( - pilot, lambda: hx.model is not None - and hx.model.row(hx.cursor // 16)[1] is not None, 20) - check("backslash opens the hex view synced to the code cursor", - app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea, - f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}") - want = app.program.read_bytes(code_ea, 1) - _, rb = hx.model.row(hx.cursor // 16) - check("hex shows the actual byte at that address", - rb is not None and rb[hx.cursor % 16] == want[0], - f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}") - await pilot.press("l") - await pilot.pause(0.1) - check("hex cursor steps one byte", hx.cursor_va() == code_ea + 1, - f"va={hx.cursor_va():#x}") - await pilot.press("backslash") # back to the code view - await wait_until(pilot, lambda: app._active != "hex", 10) - check("backslash returns from hex to the code view", - app._active == "disasm", f"active={app._active}") - - # 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.05) - await pilot.press("slash") - await pilot.pause(0.05) - 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.05) - 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.05) - check("ctrl+b again restores pane + focuses table", - left.display and isinstance(app.focused, DataTable), - f"display={left.display} focus={type(app.focused).__name__}") - - # The 'sub_1*' round-trip above left the list filtered; clear it so the - # full table is back and biggest_i (an unfiltered row index) is valid. - app.clear_filter() - await wait_until(pilot, lambda: table.row_count == nfuncs, timeout=15) - - # Tab toggles between pseudocode and disassembly (view state persists - # across opens, so normalize to pseudocode first). - 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) - if app._active != "decomp": - await pilot.press("tab") - pc = await wait_until( - pilot, lambda: dec.display and dec.loaded_ea is not None - and app._active == "decomp", 25) - check("tab shows pseudocode", pc, 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("tab") - await pilot.pause(0.1) - check("tab switches to disassembly", - app._active == "disasm" and dis.display and not dec.display, - f"active={app._active}") - await pilot.press("tab") # back to pseudocode for the overlay/gutter checks - await wait_until(pilot, lambda: app._active == "decomp", 10) - - # A (re)decompile shows a grayed 'decompiling…' overlay while it runs. - if app._active != "decomp": - await pilot.press("tab") - await wait_until(pilot, lambda: app._active == "decomp", timeout=10) - await wait_until(pilot, lambda: dec.loaded_ea is not None, timeout=20) - dec.loaded_ea = None # force a reload (as rename/refresh does) - app._show_active() - cover = dec._cover_widget - check("decompile shows a 'decompiling…' overlay", - dec.loading and cover is not None - and "decomp-loading" in cover.classes - and "decompiling" in str(cover.render()), - f"loading={dec.loading} cover={cover!r}") - await wait_until(pilot, lambda: not dec.loading and dec._cover_widget is None, - timeout=25) - check("overlay clears when the decompile finishes", - not dec.loading and dec._cover_widget is None) - # Line-number gutter. - dec.scroll_to(0, 0, animate=False) - await pilot.pause(0.025) - row0 = "".join(seg.text for seg in dec.render_line(0)) - check("pseudocode has a numbered gutter (line 1 first)", - dec._gutter > 0 and row0[:dec._gutter].strip() == "1", - f"gutter={dec._gutter} row0={row0[:10]!r}") - await pilot.press("tab") # back to disasm for the rest of the tests - await wait_until(pilot, lambda: app._active == "disasm", timeout=10) - - # 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.1) - 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))) - check("search cursor lands on the match's starting column", - dis.cursor_x == dis._ranges[dis.cursor][0][0], - f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}") - prev = dis.cursor - await pilot.press("slash") - await pilot.pause(0.05) - await pilot.press("enter") - await pilot.pause(0.05) - 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.05) - await pilot.press("enter") - await pilot.pause(0.05) - 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.1) - check("search bar visible, status hidden (no overlap)", - si.display and not status.display, f"si={si.display} status={status.display}") - from textual.widgets import Footer as _Footer - footer = app.query_one(_Footer) - check("search input is rendered above the footer (not overlapping)", - si.region.height >= 1 and si.region.y < footer.region.y, - f"search={si.region} footer={footer.region}") - for ch in term: - await pilot.press(ch) - await pilot.pause(0.05) - 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.1) - 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)}") - - # Incremental function-name filter + highlight + Esc-clear. - table.focus() - await pilot.pause(0.05) - if app._filter_term: # clear any leftover filter from earlier - await pilot.press("escape") - await pilot.pause(0.1) - full = table.row_count - await pilot.press("slash") - await pilot.pause(0.1) - for ch in "sub_": - await pilot.press(ch) - await pilot.pause(0.05) - await pilot.pause(0.1) - check("filter narrows incrementally as you type", - 0 < table.row_count < full, f"{table.row_count}/{full}") - cell = table.get_row_at(0)[1] - check("filter highlights matched substring in name", - isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell))) - await pilot.press("enter") - await pilot.pause(0.1) - check("Enter keeps filter + focuses table", - isinstance(app.focused, DataTable) and table.row_count < full) - await pilot.press("escape") - await pilot.pause(0.15) - check("Esc on the list clears the filter", table.row_count == full, - f"{table.row_count}/{full}") - - # Follow (Enter) + back (Esc) + xrefs (x), using a real call site. - dis = app.query_one(DisasmView) - dis.focus() - await pilot.pause(0.05) - lines = dis.model.lines(0, 400, prefetch=False) - call_idx = next((i for i, ln in enumerate(lines) - if ln.text.startswith("call ")), None) - if call_idx is None: - check("found a call line to exercise follow/xrefs", False, "no call in first 400") - else: - dis.cursor = call_idx - dis.refresh() - orig = app._cur.ea - orig_name = app._cur.name - depth = len(app._nav) - await pilot.press("enter") - await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) - check("Enter follows the call into another function", - app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}") - await pilot.press("escape") - await pilot.pause(0.1) - check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}") - dis.cursor = call_idx - dis.refresh() - await pilot.press("x") - opened = await wait_until( - pilot, lambda: isinstance(app.screen, XrefsScreen), timeout=25) - check("'x' opens the xrefs popup", opened, - f"screen={type(app.screen).__name__}") - if opened: - check("xrefs popup has entries", - app.screen.query_one(OptionList).option_count >= 1) - await pilot.press("escape") - await pilot.pause(0.1) - check("Esc closes the xrefs popup", - not isinstance(app.screen, XrefsScreen)) - - # Selecting an xref must land on the referencing SITE (right function - # AND line), not the function under the cursor. - xf = None - for cand in app.program.functions().all_loaded()[:600]: - codex = [x for x in app.program.xrefs_to(cand.addr) - if x.type == "code" and x.frm] - if codex: - xf = (cand, codex[0]) - break - if xf is None: - check("found a function with a code xref", False) - else: - xfn, xref = xf - xexp = app.program.disasm(xref.fn_addr).index_of_ea(xref.frm) - await pilot.press("g") - await pilot.pause(0.1) - for ch in xfn.name: - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: dis.total > 0 and app._cur.ea == xfn.addr, - timeout=20) - dis.focus() - dis.cursor, dis.cursor_x = 0, 0 # on the entry (address column) - await pilot.press("x") - await wait_until(pilot, lambda: isinstance(app.screen, XrefsScreen), 25) - app.screen.query_one(OptionList).highlighted = 0 - await pilot.press("enter") - await wait_until(pilot, lambda: not isinstance(app.screen, XrefsScreen), 25) - await wait_until(pilot, lambda: app._cur.ea == xref.fn_addr, timeout=25) - await pilot.pause(0.15) - check("xref-select lands on the referencing function + line", - app._cur.ea == xref.fn_addr and dis.cursor == xexp, - f"cur={app._cur.ea:#x} (want {xref.fn_addr:#x}) " - f"cursor={dis.cursor} (want {xexp})") - - # Same jump, but with the DECOMPILER pane active: it must land on - # the reference LINE in pseudocode, not the top of the function. - # (Repro of the reported bug; _on_xref_chosen is the dialog's - # selection callback, the exact seam that was broken.) - dec = app.query_one(DecompView) - dcode = app.program.decompile(xref.fn_addr) - dexp, be = -1, -1 # expected pseudocode line for the ref site - if not dcode.failed and dcode.code: - for i, ln in enumerate(dcode.code.splitlines()): - for mm in re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", ln): - e = int(mm, 16) - if be < e <= xref.frm: - be, dexp = e, i - if dexp >= 0: - app._pref = "decomp" # navigation honours the preferred view - app._open_function(xfn.addr, xfn.name) # start elsewhere - await wait_until(pilot, lambda: app._cur.ea == xfn.addr - and app._active == "decomp", 20) - await wait_until(pilot, lambda: dec.loaded_ea == xfn.addr, 25) - # xfn is the referenced function; its name is the token that - # appears at the call site the xref points to. - app._xref_focus_name = xfn.name - app._on_xref_chosen(xref.frm) # pick the caller from the dialog - await wait_until(pilot, lambda: dec.loaded_ea == xref.fn_addr, 25) - await pilot.pause(0.1) - check("xref-select lands on the reference LINE in pseudocode", - dec.cursor == dexp, f"dec.cursor={dec.cursor} want={dexp}") - # Cursor lands on the reference token's column (or the line - # start if that token isn't literally on the line, e.g. a - # stale Hex-Rays cache renders a different name). - landed = dec._texts[dec.cursor] if dec.cursor < len(dec._texts) else "" - tokm = re.search(rf"\b{re.escape(xfn.name)}\b", landed) - want_col = tokm.start() if tokm else 0 - check("xref-select lands the column on the reference token", - dec.cursor_x == want_col, - f"cursor_x={dec.cursor_x} want={want_col} " - f"token={xfn.name!r} line={landed.strip()!r}") - - # And a jump whose target is INSIDE the already-displayed - # function must still move the cursor (the decompiler pane - # isn't reloaded in that case, so it needs explicit repos). - anch = [(i, dec._line_ea(i)) for i in range(len(dec._texts)) - if dec._line_ea(i) is not None] - if len(anch) >= 4: - start_ln = anch[1][0] - far_ea = anch[len(anch) // 2][1] - exp2 = app._decomp_line_for(app._cur.ea, far_ea) - dec.focus() - dec.cursor = start_ln - dec.refresh() - await pilot.pause(0.05) - app._on_xref_chosen(far_ea) # target within this function - await wait_until(pilot, lambda: dec.cursor == exp2, timeout=20) - check("xref-select moves the cursor within the same function", - dec.cursor == exp2 and exp2 != start_ln, - f"dec.cursor={dec.cursor} want={exp2} start={start_ln}") - app._pref = "disasm" # restore for the following blocks - app._active = "disasm" - app._show_active() - await pilot.pause(0.05) - - # The xref-select sub-test above navigated 'dis' to another function; - # return to the original and materialize the call line so the column - # cursor operates on a cached (non-empty) line. - app._open_function(orig, orig_name) - await wait_until(pilot, lambda: dis.total > 0 and app._cur.ea == orig, 20) - dis.model.lines(0, 400, prefetch=False) - # Column cursor: h/l move it; following the symbol under the cursor. - dis.focus() - dis.cursor = call_idx - dis.cursor_x = 5 - dis.refresh() - await pilot.pause(0.025) - await pilot.press("h") - check("h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}") - await pilot.press("l") - await pilot.press("l") - check("l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}") - plain = dis._line_plain(call_idx) or "" - m = re.search(r"\b(sub_[0-9A-Fa-f]+)", plain) - if m: - dis.cursor = call_idx - dis.cursor_x = m.start(1) + 1 - dis.refresh() - await pilot.pause(0.05) - check("word-under-cursor is the operand symbol", - dis.word_under_cursor() == m.group(1), - f"{dis.word_under_cursor()!r} vs {m.group(1)!r}") - depth = len(app._nav) - want = app.program.resolve(m.group(1)) - await pilot.press("enter") - await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) - check("follows the symbol under the cursor", - app._cur.ea == want, f"cur={app._cur.ea:#x} want={want:#x}") - - # Xref labels: function + offset (so several sites in one function are - # distinguishable) and a section name instead of a bare '?' for a - # reference that isn't inside a function. - from collections import Counter, defaultdict - multi = None - for cand in app.program.functions().all_loaded()[:200]: - callers = Counter(x.fn_addr for x in app.program.xrefs_to(cand.addr) - if x.fn_name) - if any(n >= 2 for n in callers.values()): - multi = cand - break - if multi is not None: - table.focus() - await pilot.press("g") - await pilot.pause(0.1) - for ch in multi.name: - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: app._cur and app._cur.ea == multi.addr, 20) - if app._active != "disasm": - await pilot.press("tab") - dis.focus() - dis.cursor, dis.cursor_x = 0, 0 - dis.refresh() - await pilot.press("x") - await wait_until(pilot, lambda: isinstance(app.screen, XrefsScreen), 25) - labels = [t for _, t in app.screen._items] - # location column = the text between the leading addr and the [type] - locs = [l.split("[")[0].split(None, 1)[1].strip() for l in labels] - byfn = defaultdict(set) - for x in locs: - if "+0x" in x: - nm, off = x.split("+0x", 1) - byfn[nm].add(off) - check("xref labels distinguish multiple sites in a function by offset", - any(len(offs) >= 2 for offs in byfn.values()), f"locs={locs[:8]}") - check("no xref label is a bare '?'", - all(x != "?" for x in locs), f"locs={locs[:8]}") - await pilot.press("escape") - await wait_until(pilot, lambda: not isinstance(app.screen, XrefsScreen), 25) - - # Pre-selection: invoking 'x' at a call SITE highlights that very - # site in the dialog, so a long list can be stepped without losing - # your place. Use the decompiler (the exact-match seam). - bycaller = defaultdict(list) - for x in app.program.xrefs_to(multi.addr): - if x.fn_name and x.type == "code": - bycaller[x.fn_addr].append(x) - csites = next((sorted(v, key=lambda x: x.frm) - for v in bycaller.values() if len(v) >= 2), None) - if csites: - site = csites[len(csites) // 2] - await pilot.press("g") - await pilot.pause(0.1) - for ch in hex(site.fn_addr): - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: app._cur and app._cur.ea == site.fn_addr, 20) - if app._active != "decomp": - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == site.fn_addr, 25) - li = app._decomp_line_for(site.fn_addr, site.frm) - col = dec._texts[li].find(multi.name) if 0 <= li < len(dec._texts) else -1 - if col >= 0: - dec.focus() - dec.cursor, dec.cursor_x = li, col + 1 - dec.refresh() - await pilot.pause(0.05) - await pilot.press("x") - await wait_until(pilot, lambda: isinstance(app.screen, XrefsScreen), 25) - hl = app.screen.query_one(OptionList).highlighted - it = app.screen._items - check("xref dialog pre-selects the site it was invoked from", - hl is not None and it[hl][0] == site.frm, - f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} " - f"want={hex(site.frm)}") - await pilot.press("escape") - await wait_until(pilot, lambda: not isinstance(app.screen, XrefsScreen), 25) - else: - check("found the callee token for preselect", False) - if app._active != "disasm": # restore for the following mouse test - await pilot.press("tab") - await wait_until(pilot, lambda: app._active == "disasm", 10) - else: - check("found a caller with multiple sites for preselect", False) - else: - check("found a function with multiple same-caller xrefs", False) - - # Mouse: single click places the cursor; double-click follows. - table.focus() - table.move_cursor(row=biggest_i) - await pilot.press("enter") - await wait_until(pilot, lambda: dis.total > 0, timeout=20) - dis.focus() - await pilot.pause(0.1) - mrow = mcol = msym = mline = None - for _ in range(40): # page down until a 'call sub_' is on screen - top = round(dis.scroll_offset.y) - dis.model.lines(top, dis.size.height + 2, prefetch=False) - for r in range(min(dis.size.height, dis.total - top)): - plain = dis._line_plain(top + r) - if plain and "call" in plain: - mm = re.search(r"\b(sub_[0-9A-Fa-f]+)", plain) - if mm: - mrow, mcol, msym, mline = r, mm.start(1), mm.group(1), top + r - break - if mrow is not None: - break - await pilot.press("pagedown") - await pilot.pause(0.025) - if mrow is None: - check("found a call line for the mouse test", False, "no visible call sub_") - else: - await pilot.click(dis, offset=(mcol + 1, mrow)) # +1: left padding - await pilot.pause(0.05) - check("single click places the cursor on the clicked token", - dis.cursor == mline and dis.word_under_cursor() == msym, - f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}") - depth = len(app._nav) - want = app.program.resolve(msym) - await pilot.click(dis, offset=(mcol + 1, mrow), times=2) - await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) - check("double-click follows the symbol", app._cur.ea == want, - f"cur={app._cur.ea:#x} want={want:#x}") - # Back restores the exact cursor position (line AND column). - await pilot.press("escape") - await wait_until(pilot, lambda: app._cur.ea != want, timeout=20) - await wait_until(pilot, lambda: dis.total > 0 and dis.cursor == mline, timeout=20) - check("back restores the exact line + column", - dis.cursor == mline and dis.word_under_cursor() == msym, - f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}") - - # Pseudocode-view position is tracked independently across jumps. - prog = app.program - fi = prog.functions() - fi.ensure(500) - pick = None - for fn in fi.all_loaded()[:500]: - d = prog.decompile(fn.addr) - if d.failed or not d.code: - continue - dl = d.code.split("\n") - if len(dl) < 12: - continue - for i, txt in enumerate(dl): - if i < 8: - continue - dm2 = re.search(r"\b(sub_[0-9A-Fa-f]+)", txt) - if dm2 and dm2.group(1) != fn.name: - pick = (fn, i, dm2.start(1), dm2.group(1)) - break - if pick: - break - if pick is None: - check("found a pseudocode line to test decomp nav", False) - else: - fn, drow, dcol, dsym = pick - await pilot.press("g") - await pilot.pause(0.1) - for ch in fn.name: - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: dis.total > 0, timeout=20) - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=20) - dec.cursor = drow - dec.cursor_x = dcol - dec._after_cursor_move() - await pilot.pause(0.05) - depth = len(app._nav) - await pilot.press("enter") # follow the sym under the pseudocode cursor - await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) - await pilot.press("escape") - await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=25) - await pilot.pause(0.1) - check("pseudocode-view position restored after jump+back", - dec.cursor == drow and dec.word_under_cursor() == dsym, - f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}") - - # Follow must still work when the name is stale (as right after a - # rename, before the pseudocode text catches up): the ea-marker - # fallback follows by address regardless of the (old) token. - dstale = app.program.resolve(dsym) - old_line = dec._texts[drow] if drow < len(dec._texts) else "" - old_ea = dec._line_ea(drow) - if old_ea is not None and dsym in old_line: - tmpname = f"stale_{os.getpid()}" - app.program.client.call( - "rename", batch={"func": {"addr": hex(dstale), "name": tmpname}}) - app.program.bump_names() - d2 = len(app._nav) - # dsym is now the OLD name; follow must fall back to the line's ea - app._follow_decomp(old_line, dsym, old_ea) - await wait_until(pilot, lambda: len(app._nav) > d2, timeout=25) - check("decomp follow works with a stale name (ea-marker fallback)", - app._cur.ea == dstale, f"cur={app._cur.ea:#x} want={dstale:#x}") - app.program.client.call( - "rename", batch={"func": {"addr": hex(dstale), "name": dsym}}) - app.program.bump_names() - - # Column sort: click headers to sort by name / address. - if app._filter_term: - app._apply_filter("") - await pilot.pause(0.05) - await pilot.click(table, offset=(15, 0)) # Function header - await pilot.pause(0.15) - snames = [str(table.get_row_at(i)[1]) for i in range(min(20, table.row_count))] - check("click Function header sorts by name", - app._sort_col == 1 and snames == sorted(snames, key=str.lower), - f"sort_col={app._sort_col}") - first_asc = str(table.get_row_at(0)[1]) - await pilot.click(table, offset=(15, 0)) # reverse - await pilot.pause(0.15) - check("click again reverses the sort", - app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc) - await pilot.click(table, offset=(3, 0)) # Address header - await pilot.pause(0.15) - saddrs = [int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))] - check("click Address header sorts by address", - app._sort_col == 0 and saddrs == sorted(saddrs), f"sort_col={app._sort_col}") - - # Rename via 'n' (reuse the pseudocode sub_ ref found above). - if pick is not None: - fn, drow, dcol, dsym = pick - dtarget = app.program.resolve(dsym) - await pilot.press("g") - await pilot.pause(0.1) - for ch in fn.name: - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: dis.total > 0, timeout=20) - if app._active != "decomp": - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=20) - dec.focus() - dec.cursor, dec.cursor_x = drow, dcol + 1 - dec.refresh() - await pilot.pause(0.05) - newname = f"ren_{os.getpid()}" - await pilot.press("n") - await pilot.pause(0.1) - ri = app.query_one("#rename", Input) - check("'n' opens the rename prompt prefilled with the symbol", - ri.display and ri.value == dsym, f"val={ri.value!r}") - ri.value = newname - await pilot.press("enter") - await wait_until( - pilot, - lambda: app._func_index.by_addr(dtarget) - and app._func_index.by_addr(dtarget).name == newname, - timeout=25, - ) - check("rename updates the function name", - app._func_index.by_addr(dtarget).name == newname, - app._func_index.by_addr(dtarget).name) - # revert via the API for reliable, race-free cleanup - rr = app.program.client.call( - "rename", batch={"func": {"addr": hex(dtarget), "name": dsym}}) - check("rename reverted cleanly", - rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary"))) - - # A Hex-Rays goto label has no rename API; pressing 'n' on one must be - # refused up front with a clear message, not open a prompt that then - # fails with a misleading 'local variable not found'. - def _find_label(): - for i, t in enumerate(dec._texts): - mm = re.search(r"\bLABEL_\d+\b", t) - if mm: - return i, mm.start(), mm.group(0) - return None - lab = _find_label() - if lab is None: # current func has none; main reliably has labels - await pilot.press("g") - await pilot.pause(0.1) - for ch in "main": - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: app._cur and app._cur.name == "main", 20) - if app._active != "decomp": - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == app._cur.ea, 25) - lab = _find_label() - if lab is not None: - li, lc, ltok = lab - dec.focus() - dec.cursor, dec.cursor_x = li, lc + 1 - dec.refresh() - await pilot.pause(0.05) - await pilot.press("n") - await pilot.pause(0.1) - ri2 = app.query_one("#rename", Input) - st = str(app.query_one("#status").render()) - check("renaming a pseudocode label is refused with a clear message", - (not ri2.display) and "label" in st.lower(), - f"display={ri2.display} status={st!r}") - else: - check("found a pseudocode label to test", False, "no LABEL_ found") - - # Add a comment on the current pseudocode line via ';' and confirm it - # renders after the decompiler refreshes. - cline = next((i for i in range(len(dec._texts)) - if i > 5 and dec._line_ea(i) is not None - and dec._texts[i].strip() and "//" not in dec._texts[i]), None) - if cline is not None: - cea = dec._line_ea(cline) - dec.focus() - dec.cursor, dec.cursor_x = cline, 2 - dec.refresh() - await pilot.pause(0.05) - await pilot.press("semicolon") - await pilot.pause(0.1) - ci = app.query_one("#comment", Input) - cnote = f"note_{os.getpid()}" - check("';' opens the comment prompt on the current line", ci.display, - f"display={ci.display}") - ci.value = cnote - await pilot.press("enter") - await wait_until( - pilot, lambda: dec.loaded_ea == app._cur.ea - and any(cnote in t for t in dec._texts), timeout=25) - check("comment appears in the pseudocode after ';'", - any(cnote in t for t in dec._texts), "comment not shown") - # revert via the API for clean, race-free cleanup - app.program.client.call( - "set_comments", items=[{"addr": hex(cea), "comment": ""}]) - else: - check("found a pseudocode line to comment", False, "no marker line") - - # Retype ('y') a FUNCTION prototype: the prompt prefills the current - # prototype (from the structured func_types tool) and applying a new - # one updates it. (Local-var retyping uses the same seam via - # set_lvar_type; it's not asserted here because Hex-Rays restructures - # some vars on retype, making a deterministic check fragile.) - cf = app._cur # whatever function is actually loaded now - await wait_until(pilot, lambda: dec.loaded_ea == cf.ea, 15) - fts = app.program.func_types(cf.ea) - if fts is not None and fts.prototype: - old_proto = fts.prototype - nm_col = dec._texts[0].find(cf.name) - dec.focus() - dec.cursor, dec.cursor_x = 0, (nm_col + 1 if nm_col >= 0 else 0) - dec.refresh() - await pilot.pause(0.05) - await pilot.press("y") - await wait_until(pilot, lambda: app.query_one("#retype", Input).display, 10) - ri = app.query_one("#retype", Input) - check("'y' on a function prefills its prototype", - ri.display and ri.value == old_proto, - f"val={ri.value!r} want={old_proto!r}") - ri.value = f"void __fastcall {cf.name}(int zz_retype_arg)" - await pilot.press("enter") - await wait_until( - pilot, lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)( - app.program.func_types(cf.ea)), 20) - after = app.program.func_types(cf.ea) - check("applying a retype changes the function prototype", - after is not None and "zz_retype_arg" in after.prototype, - f"proto={after.prototype if after else None!r}") - app.program.set_function_type(cf.ea, old_proto) # restore - else: - check("got structured function types (func_types tool)", False) - - # Decompiler follow of a name NOT in refs (the function's own name). - await pilot.press("g") - await pilot.pause(0.1) - for ch in fn.name: - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: dis.total > 0, timeout=20) - if app._active != "decomp": - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=20) - nx = dec._texts[0].find(fn.name) if dec._texts else -1 - if nx >= 0: - dec.focus() - dec.cursor, dec.cursor_x = 0, nx + 1 - dec.refresh() - await pilot.pause(0.05) - depth = len(app._nav) - await pilot.press("enter") - moved = await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) - check("decompiler follows a name not in refs (resolve fallback)", - moved and app._cur.ea == fn.addr, f"moved={moved}") - - # Scroll position (not just the cursor) is restored on back, both views. - async def goto_name(name): - await pilot.press("g") - await pilot.pause(0.1) - for ch in name: - await pilot.press(ch) - await pilot.press("enter") - - # pick two distinct, decently-sized functions - big = sorted(fi.all_loaded(), key=lambda f: f.size, reverse=True) - fa = next((f for f in big if f.size > 0x400), big[0]) - fb = next((f for f in big if f.addr != fa.addr), big[-1]) - await goto_name(fa.name) - await wait_until(pilot, lambda: dis.total > 40 and app._cur.ea == fa.addr, timeout=20) - if app._active != "disasm": - await pilot.press("tab") - await pilot.pause(0.1) - for _ in range(4): - await pilot.press("ctrl+d") - await pilot.pause(0.1) - # Put the cursor in the MIDDLE of the viewport so the test is - # non-degenerate: with rel>0, restoring only the cursor (scroll-into-view) - # would derive a different scroll, so this actually checks scroll restore. - base = round(dis.scroll_offset.y) - mid = base + min(dis.size.height // 2, max(dis.total - base - 1, 0)) - dis.cursor, dis.cursor_x = mid, 0 - dis.refresh() - dis._after_cursor_move() - await pilot.pause(0.05) - want_sy, want_cur = round(dis.scroll_offset.y), dis.cursor - want_rel = want_cur - want_sy - await goto_name(fb.name) - await wait_until(pilot, lambda: app._cur.ea == fb.addr, timeout=20) - # Record the scroll at each repaint so we catch the "scroll_offset moved - # but the pane wasn't repainted" bug (which scroll_offset checks miss). - renders: list[int] = [] - _orig_rl = dis.render_line - def _traced(y, _o=_orig_rl): - if y == 0: - renders.append(round(dis.scroll_offset.y)) - return _o(y) - dis.render_line = _traced - await pilot.press("escape") - await wait_until(pilot, lambda: app._cur.ea == fa.addr, timeout=20) - await wait_until(pilot, lambda: dis.total > 40, timeout=20) - await pilot.pause(0.25) - check("disasm scroll + cursor restored on back (mid-viewport)", - round(dis.scroll_offset.y) == want_sy and dis.cursor == want_cur and want_rel > 0, - f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) " - f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}") - check("pane is repainted at the restored scroll (no stale top frame)", - bool(renders) and renders[-1] == want_sy, - f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})") - dis.render_line = _orig_rl - - # PageUp/Down keep the cursor at the same viewport-relative row. - await goto_name(fa.name) - await wait_until(pilot, lambda: dis.total > 100 and app._cur.ea == fa.addr, timeout=20) - if app._active != "disasm": - await pilot.press("tab") - await pilot.pause(0.1) - for _ in range(6): - await pilot.press("j") - await pilot.pause(0.05) - rel = dis.cursor - round(dis.scroll_offset.y) - await pilot.press("pagedown") - await pilot.pause(0.05) - check("PageDown preserves the viewport-relative row", - dis.cursor - round(dis.scroll_offset.y) == rel, - f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") - await pilot.press("pageup") - await pilot.pause(0.05) - check("PageUp preserves the viewport-relative row", - dis.cursor - round(dis.scroll_offset.y) == rel, - f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") - - # Rename refresh across history: rename a callee while viewing it, then - # 'back' to the caller (cached earlier) shows the new name. - table.focus() - table.move_cursor(row=biggest_i) - await pilot.press("enter") - await wait_until(pilot, lambda: dis.total > 0, timeout=20) - bea = app._cur.ea - # cache the caller's pseudocode too, so the stale-decomp path is exercised - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == bea, timeout=20) - await pilot.press("tab") - dis.focus() - await pilot.pause(0.1) - hrow = hsym = None - for _ in range(40): - top = round(dis.scroll_offset.y) - dis.model.lines(top, dis.size.height + 2, prefetch=False) - for r in range(min(dis.size.height, dis.total - top)): - p = dis._line_plain(top + r) - if p and "call sub_" in p: - mm = re.search(r"\bcall (sub_[0-9A-Fa-f]+)", p) - if mm: - hrow, hsym = top + r, mm.group(1) - break - if hrow is not None: - break - await pilot.press("pagedown") - await pilot.pause(0.025) - if hrow is not None: - htarget = app.program.resolve(hsym) - await pilot.press("g") - await pilot.pause(0.1) - for ch in hsym: - await pilot.press(ch) - await pilot.press("enter") - await wait_until(pilot, lambda: app._cur.ea == htarget, timeout=20) - if app._active != "decomp": - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == htarget, timeout=20) - nx = dec._texts[0].find(hsym) if dec._texts else -1 - if nx >= 0: - dec.focus() - dec.cursor, dec.cursor_x = 0, nx + 1 - dec.refresh() - await pilot.pause(0.05) - hnew = f"h_{os.getpid()}" - await pilot.press("n") - await pilot.pause(0.1) - app.query_one("#rename", Input).value = hnew - await pilot.press("enter") - await wait_until( - pilot, - lambda: app._func_index.by_addr(htarget) - and app._func_index.by_addr(htarget).name == hnew, - timeout=25, - ) - if app._active != "disasm": - await pilot.press("tab") - await pilot.press("escape") - await wait_until( - pilot, lambda: dis.total > 0 and app._cur.ea != htarget, timeout=25) - await pilot.pause(0.2) - dis.model.lines(hrow, 4, prefetch=False) - await pilot.pause(0.1) - hline = dis._line_plain(hrow) - check("caller disasm shows renamed callee after 'back'", - hline is not None and hnew in hline, f"line={hline!r}") - # and the caller's cached pseudocode must refresh too - await pilot.press("tab") - await wait_until(pilot, lambda: dec.loaded_ea == bea, timeout=25) - await pilot.pause(0.15) - check("caller pseudocode shows renamed callee after 'back'", - any(hnew in tx for tx in dec._texts), - "pseudocode still stale") - app.program.client.call( - "rename", batch={"func": {"addr": hex(htarget), "name": hsym}}) - - -def main(argv): - global STOP_AFTER - db = None - it = iter(argv) - for a in it: - if a == "--db": - db = next(it) - elif a == "--stop-after": - # Iterate fast on one feature: run up to (and including) the first - # check whose name contains this substring, then stop (skips the - # expensive tail). Full run is the default (no flag). - STOP_AFTER = next(it) - try: - asyncio.run(run(db)) - except _StopSuite: - print(f" … stopped after '{STOP_AFTER}'") - print(f"\n{PASS} passed, {FAIL} failed") - return 1 if FAIL else 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) |
