aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--tests/test_tui.py1236
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:]))