aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_scenarios.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_scenarios.py')
-rw-r--r--tests/test_scenarios.py1562
1 files changed, 1498 insertions, 64 deletions
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 09e1ac5..5297f0c 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -16,32 +16,95 @@ Uses ~/ida-venv python (has textual).
--stop-after <substr> stop once a check whose name contains <substr> ran
--list print scenario names and exit
"""
+
+#: the pilot suite: one real worker, 56 scenarios.
+#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
+NEEDS_IDA = True
import asyncio
import fnmatch
import os
import re
-import shutil
import sys
-import tempfile
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _fixtures import fast_keys, staged # noqa: E402
+
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
from idatui.app import ( # noqa: E402
- ConfirmScreen, DecompView, FunctionsPanel, HexView, IdaTui,
- HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
- SymbolPalette, XrefsScreen, _str_display, _word_occurrences,
+ ConfirmScreen, DecompView, FunctionsPanel, GraphView, HexView, IdaTui,
+ HelpScreen, ListingView, QuitScreen, SearchPalette, StringsPalette,
+ StructEditor, SymbolPalette, XrefsScreen, _HELP, _str_display,
+ _word_occurrences,
)
+from idatui.errors import IDAToolError # noqa: E402
from textual.widgets import ( # noqa: E402
DataTable, Input, OptionList, Static, TextArea,
)
from rich.text import Text # noqa: E402
-from idatui._sync import wait_for # noqa: E402
+from idatui._sync import settle, wait_for # noqa: E402
PASS = FAIL = 0
STOP_AFTER = None
SCENARIOS: list[tuple[str, object]] = []
+class _Profile:
+ """Where the suite's wall clock goes, per scenario.
+
+ Two numbers matter and neither is visible from a scenario's total: seconds
+ spent in FIXED pauses (a guess about a state we could have observed), and
+ waits that ran out their timeout -- those cost the full timeout AND let the
+ check after them pass vacuously. ``--profile`` prints both.
+ """
+
+ def __init__(self) -> None:
+ self.enabled = False
+ self.paused: dict[str, float] = {}
+ self.waited: dict[str, float] = {}
+ self.pressed: dict[str, float] = {}
+ self.expired: list[tuple[str, float]] = []
+
+ def pause(self, scenario: str, secs: float) -> None:
+ if self.enabled:
+ self.paused[scenario] = self.paused.get(scenario, 0.0) + secs
+
+ def press(self, scenario: str, secs: float) -> None:
+ if self.enabled:
+ self.pressed[scenario] = self.pressed.get(scenario, 0.0) + secs
+
+ def wait(self, scenario: str, secs: float, ok: bool, line: int = 0) -> None:
+ if not self.enabled:
+ return
+ self.waited[scenario] = self.waited.get(scenario, 0.0) + secs
+ if not ok:
+ self.expired.append((f"{scenario} (line {line})", secs))
+
+ def report(self) -> None:
+ if not self.enabled:
+ return
+ tp, tw = sum(self.paused.values()), sum(self.waited.values())
+ tk = sum(self.pressed.values())
+ print(f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, "
+ f"{tk:.1f}s in keystrokes")
+ rows = sorted(self.paused.items(), key=lambda kv: -kv[1])[:8]
+ for name, secs in rows:
+ print(f" pause {secs:5.2f}s {name}")
+ rows = sorted(self.waited.items(), key=lambda kv: -kv[1])[:8]
+ for name, secs in rows:
+ print(f" wait {secs:5.2f}s {name}")
+ rows = sorted(self.pressed.items(), key=lambda kv: -kv[1])[:8]
+ for name, secs in rows:
+ print(f" keys {secs:5.2f}s {name}")
+ for name, secs in self.expired:
+ print(f" EXPIRED wait {secs:5.2f}s in {name} "
+ f"(the check after it may have passed vacuously)")
+
+
+PROFILE = _Profile()
+
+
class _StopSuite(Exception):
pass
@@ -56,6 +119,12 @@ def scenario(name):
# --------------------------------------------------------------------------- #
# Shared context + helpers
# --------------------------------------------------------------------------- #
+#: What the app says when Hex-Rays can't decompile (idatui/app.py,
+#: _apply_decomp). Waiting on the wrong text here doesn't fail a test -- it
+#: times out and then lets a weaker check pass, which is far more expensive.
+_CANNOT_DECOMP = "cannot decompile"
+
+
class Ctx:
def __init__(self, app, pilot):
self.app = app
@@ -78,14 +147,52 @@ class Ctx:
raise _StopSuite
async def wait(self, pred, t=20.0, step=0.02):
- return await wait_for(pred, self.pilot.pause, t, step)
+ t0 = asyncio.get_event_loop().time()
+ ok = await wait_for(pred, self.pilot.pause, t, step)
+ PROFILE.wait(self.scenario, asyncio.get_event_loop().time() - t0, ok,
+ sys._getframe(1).f_lineno)
+ return ok
async def press(self, *keys):
+ """Send keys, then wait for the app to finish reacting to them.
+
+ The wait is ours, deliberately. Textual's own ``press`` ends with two
+ ``wait_for_idle`` sleeps per key (~85ms), which is a CPU-load heuristic
+ standing in for a gate -- and scenarios came to lean on it, so removing
+ it alone broke nine checks that read state straight after a keypress.
+ ``settle`` is the real thing (pump drained, workers finished) at ~2ms,
+ and it holds on a loaded box where the heuristic is exactly as likely to
+ return early.
+ """
+ t0 = asyncio.get_event_loop().time()
for k in keys:
await self.pilot.press(k)
+ await settle(self.app, timeout=5.0)
+ PROFILE.press(self.scenario, asyncio.get_event_loop().time() - t0)
async def pause(self, d=0.05):
+ """Yield until the app has finished reacting to what we just did.
+
+ This used to be a flat ``asyncio.sleep(d)``, and across ~140 call sites
+ that was 20s of the suite's 62s spent asleep -- a guess about a state we
+ can observe directly. ``settle`` drains the message pump and waits for
+ the threaded workers, so it returns the moment the app is quiescent
+ (single-digit ms in the common case) and, unlike a sleep, it does not
+ silently pass when the machine is loaded and the work took longer than
+ the guess. ``d`` survives as the upper BOUND, not as the cost.
+
+ Use :meth:`sleep` for the rare thing that is genuinely gated on a timer.
+ """
+ t0 = asyncio.get_event_loop().time()
+ await settle(self.app, timeout=max(d, 2.0))
+ PROFILE.pause(self.scenario, asyncio.get_event_loop().time() - t0)
+
+ async def sleep(self, d):
+ """A real wall-clock sleep -- only for what a TIMER drives (throttles,
+ debounces, blink), where there is no worker to wait for."""
+ t0 = asyncio.get_event_loop().time()
await self.pilot.pause(d)
+ PROFILE.pause(self.scenario, asyncio.get_event_loop().time() - t0)
async def type(self, text):
for ch in text:
@@ -121,9 +228,23 @@ class Ctx:
# -- discovery -------------------------------------------------------- #
def all_funcs(self):
+ """Every function, always -- never the prefix that happens to be loaded.
+
+ This used to load_all() only when the index was EMPTY, so a partially
+ streamed index (non-empty but incomplete: exactly the state during boot,
+ and after any bump_items()) came back truncated. Every fixture chosen
+ through find_func/biggest then depended on how far streaming had got,
+ which is a race.
+
+ It bit graph_minimap: on an unlucky run `find_func(size > 0x300)` picked
+ a far larger function than usual, whose graph never finished inside the
+ scenario's 60s wait -- 3 failures and 65s, one run in several, with no
+ code change to blame. Deterministic fixtures or deterministic flakes,
+ pick one.
+ """
idx = self.prog.functions()
- if len(idx) == 0 and not idx.complete:
- idx.load_all() # a prior bump_items() cleared the index cache
+ if not idx.complete:
+ idx.load_all() # boot streaming, or a prior bump_items()
return idx.all_loaded()
def find_func(self, pred, limit=400):
@@ -236,8 +357,9 @@ class Ctx:
if left.display:
left.display = False
app._pref = "decomp"
- if app._active == "hex":
+ if app._active in ("hex", "graph"):
app._active = "decomp"
+ app._graph_sticky = False # else every later scenario rebuilds a graph
app._split = False
await self.pause(0.02)
@@ -444,6 +566,37 @@ async def s_asm_highlight(c: Ctx):
mn is not None, f"{code[0].spans if code else None}")
+@scenario("status_names_the_file")
+async def s_status_names_the_file(c: Ctx):
+ """The status bar always says which file you're looking at.
+
+ Obvious once several panes and a project switcher exist: with two idatui
+ windows open, or after switching binaries, "0x2490" alone doesn't say what
+ it belongs to.
+ """
+ from textual.widgets import Static
+ app = c.app
+ await c.open_biggest("listing")
+ await c.pause(0.3)
+ name = os.path.basename(app._open_path)
+ status = str(app.query_one("#status", Static).render())
+ c.check("the status bar names the open file",
+ status.startswith(f"[{name}]"), f"{status[:60]!r} (want [{name}])")
+
+ # It must survive the messages that WRITE the status, not just the idle one.
+ c.lst.focus()
+ await c.press("down")
+ await c.pause(0.3)
+ status = str(app.query_one("#status", Static).render())
+ c.check("and keeps naming it as you move",
+ status.startswith(f"[{name}]"), status[:60])
+
+ # The function-count message used to include the module name itself, which
+ # would now read "[echo] echo — 128 functions".
+ c.check("without saying the name twice",
+ status.count(name) == 1, status[:70])
+
+
@scenario("command_palette")
async def s_command_palette(c: Ctx):
app = c.app
@@ -506,10 +659,14 @@ async def s_help(c: Ctx):
cards = app.screen.query(".help-card")
titles = {str(w.border_title) for w in cards}
txt = " ".join(str(w.render()) for w in cards)
+ # Derived from _HELP, not hardcoded: adding a group is a normal change and
+ # shouldn't fail a test that only meant 'every group is rendered'.
c.check("each key group gets its own card",
- titles == {"Navigate", "Views", "Move", "Edit", "Search"}, f"{titles}")
+ titles == {t for t, _ in _HELP}, f"{titles}")
c.check("it documents real bindings",
"set type" in txt and "split view" in txt and "cross-references" in txt)
+ c.check("the graph keys are documented",
+ "control-flow graph" in txt and "minimap" in txt)
body = app.screen.query_one("#help-body")
c.check("the cards fit without a scrollbar at a normal size",
body.virtual_size.height <= body.size.height,
@@ -517,6 +674,16 @@ async def s_help(c: Ctx):
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
c.check("Esc closes it", not isinstance(app.screen, HelpScreen))
+ # F-keys get eaten by terminals/multiplexers upstream of us, so the
+ # cheatsheet must not be reachable ONLY through F1.
+ await c.press("H")
+ opened_h = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
+ c.check("H opens the cheatsheet too", opened_h,
+ f"screen={type(app.screen).__name__}")
+ if opened_h:
+ await c.press("H")
+ await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
+ c.check("H closes it again", not isinstance(app.screen, HelpScreen))
@scenario("strings")
@@ -561,9 +728,85 @@ async def s_strings(c: Ctx):
await c.press("escape")
+@scenario("view_modes_all_handled")
+async def s_view_modes_all_handled(c: Ctx):
+ """Every ViewMode must be handled by every switch that reads _active.
+
+ Adding "graph" meant auditing each `_active ==` in the app, and the one that
+ was missed -- _active_code_view returning None -- crashed the app the first
+ time a prompt closed in graph mode. There used to be a fifth value,
+ "disasm", assigned on one path and understood by four sites out of nine.
+
+ So: walk the enum, and for each member show it and ask the app the questions
+ it asks itself. Cheap (no worker calls, just mode switches) and it fails on
+ the next mode that forgets to appear somewhere.
+ """
+ from idatui.app import ViewMode
+ app = c.app
+ fn = await c.open_biggest("listing")
+ try:
+ for mode in ViewMode:
+ app._active = mode
+ app._show_active() # must not raise for any member
+ await c.pause(0.05)
+ view = app._active_code_view()
+ if mode in ViewMode.code_modes():
+ c.check(f"{mode.value}: _active_code_view resolves a widget",
+ view is not None, f"{mode.value} -> None")
+ c.check(f"{mode.value}: exactly one predicate is true",
+ sum((app.is_listing, app.is_decomp,
+ app.is_hex, app.is_graph)) == 1,
+ f"{mode.value}: listing={app.is_listing} "
+ f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}")
+ c.check(f"{mode.value}: in_code agrees with code_modes()",
+ app.in_code == (mode in ViewMode.code_modes()),
+ f"in_code={app.in_code} for {mode.value}")
+ c.check("every mode is a plain string over the wire",
+ all(isinstance(m, str) and m == m.value for m in ViewMode),
+ str([repr(m) for m in ViewMode]))
+ c.check("'disasm' is not a mode any more",
+ "disasm" not in {m.value for m in ViewMode},
+ str([m.value for m in ViewMode]))
+ finally:
+ # Restore through a real navigation, not by poking _active back.
+ # _show_active() tears down split state and re-points the panes as a
+ # side effect, and reset() doesn't rebuild any of that -- leaving it
+ # half-torn-down made split_view fail two scenarios later with an empty
+ # listing model, which reads as split_view's bug and isn't.
+ app._active = ViewMode.LISTING
+ await c.open(fn.addr, "listing")
+
+
@scenario("split_view")
async def s_split_view(c: Ctx):
app, lst, dec = c.app, c.lst, c.dec
+ # Count worker lookups across this whole scenario. _sync_split used to bounce
+ # off _apply_resync and back for as long as the decomp map lagged the
+ # decompiler -- one thread worker and one lookup_funcs round trip per
+ # iteration, 23,665 of them in this scenario alone (four distinct
+ # addresses; 21,156 for one of them), and an idle split view pegging the
+ # worker in the live app. The race window is real work to reproduce
+ # deliberately, but it is wide open in the flow below, so the cheap guard is
+ # to count. The bound is loose because the bug was three orders of magnitude
+ # out, not a near miss.
+ _lookups = {"n": 0}
+ _orig_call = c.prog.client.invoke
+
+ def _counting(name, *a, **kw):
+ if name == "lookup_funcs":
+ _lookups["n"] += 1
+ return _orig_call(name, *a, **kw)
+
+ c.prog.client.invoke = _counting
+ try:
+ await _split_view_body(c, app, lst, dec)
+ finally:
+ c.prog.client.invoke = _orig_call
+ c.check("split view doesn't storm the worker with function lookups",
+ _lookups["n"] < 500, f"{_lookups['n']} lookup_funcs calls")
+
+
+async def _split_view_body(c: Ctx, app, lst, dec):
await c.open_biggest("listing")
await c.press("s")
shown = await c.wait(lambda: app._split and lst.display and dec.display, 20)
@@ -734,10 +977,16 @@ async def s_fallback(c: Ctx):
await c.open(failing.addr, "listing")
c.dis.focus()
await c.press("tab")
- await c.wait(lambda: app._active == "listing"
- and "fail" in c.status().lower(), 25)
- c.check("F5/Tab on an undecompilable function falls back to the listing",
- app._active == "listing" and c.dis.display,
+ # "cannot decompile" is what _apply_decomp actually says. This waited on
+ # "fail", which never appears, so it burned the full 25s timeout and the
+ # check below then passed on _active == "listing" -- already true before Tab
+ # was pressed, since the function was opened in the listing. It asserted
+ # nothing, slowly.
+ landed = await c.wait(lambda: _CANNOT_DECOMP in c.status().lower(), 25)
+ c.check("F5/Tab on an undecompilable function says so", landed,
+ f"active={app._active} status={c.status()!r}")
+ c.check("F5/Tab on an undecompilable function falls back to a code view",
+ app.is_listing and c.dis.display,
f"active={app._active} status={c.status()!r}")
# a decompilable function F5s into pseudocode
await c.open("main", "decomp")
@@ -767,6 +1016,15 @@ async def s_structs(c: Ctx):
await c.wait(lambda: tname in ta.text and "{" in ta.text, 15)
c.check("selecting a struct shows its C definition",
tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}")
+ # The definition is C, so it must be coloured as C (no tree-sitter grammar
+ # for it: idatui.highlight fills TextArea's highlight map from Pygments).
+ names = {n for spans in ta._highlights.values() for _, _, n in spans}
+ c.check("the C definition is syntax-highlighted",
+ {"keyword", "name"} <= names, f"names={sorted(names)}")
+ styled = {s.style.color.name for s in ta.render_line(0)
+ if s.style and s.style.color}
+ c.check("highlight styles reach the rendered line", len(styled) > 1,
+ f"colors={sorted(styled)}")
app._clipboard = ""
se.query_one(TextArea).focus()
await c.press("ctrl+y")
@@ -783,6 +1041,10 @@ async def s_structs(c: Ctx):
c.check("Ctrl+S declares a new struct",
any(s.name == sname for s in se._structs), "not created")
await c.wait(lambda: "\n" in ta.text, 10)
+ c.check("editing re-highlights the definition",
+ any(n == "keyword" for spans in ta._highlights.values()
+ for _, _, n in spans),
+ f"rows={len(ta._highlights)}")
c.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}")
@@ -832,6 +1094,353 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("splash_scaling")
+async def s_splash_scaling(c: Ctx):
+ """The splash scales the logo to the pane instead of dropping it.
+
+ The bug this pins: the artwork's natural size is ~31 rows plus 10 of box
+ chrome, and the check was "do you have 41 rows?". A 31-row pane -- what a
+ split zellij window actually gives you -- was one row short, so the logo
+ silently disappeared. The terminal scales an image into whatever cell box
+ it is placed in, so there was never a reason for all-or-nothing.
+ """
+ from idatui import kittygfx
+ from idatui.app import (LOGO_CHROME_ROWS, LOGO_MIN_ROWS, LoadingScreen,
+ logo_cells)
+
+ app = c.app
+ placed: list[tuple] = []
+ real_supported, real_upload, real_place = (
+ kittygfx.supported, kittygfx.upload, kittygfx.place)
+ kittygfx.supported = lambda: True
+ kittygfx.upload = lambda *a, **k: True
+ kittygfx.place = lambda *a, **k: (placed.append(a), True)[1]
+ try:
+ for width, height in ((159, 31), (100, 30), (140, 44)):
+ await c.pilot.resize_terminal(width, height)
+ await c.pause(0.05)
+ app.push_screen(LoadingScreen("echo"))
+ await c.wait(lambda: isinstance(app.screen, LoadingScreen), 5)
+ scr = app.screen
+ # push_screen returns before compose has mounted the children.
+ await c.wait(lambda: scr._cells is not None, 5)
+ room = height - LOGO_CHROME_ROWS
+ has_image = bool(scr.query("#loading-image"))
+ c.check(f"{width}x{height}: the logo is drawn, not dropped",
+ has_image and room >= LOGO_MIN_ROWS,
+ f"image={has_image} room={room}")
+ if has_image:
+ cols, rows = scr._cells
+ c.check(f"{width}x{height}: scaled to the room available",
+ rows <= room and rows == min(room, logo_cells()[1]),
+ f"cells={scr._cells} room={room} natural={logo_cells()}")
+ await c.wait(lambda: scr.query_one("#loading-box").region.height > 0, 5)
+ box = scr.query_one("#loading-box").region
+ c.check(f"{width}x{height}: the box is not clipped",
+ box.y >= 0 and box.y + box.height <= height,
+ f"box={box} screen={height}")
+ app.pop_screen()
+ await c.pause(0.05)
+ c.check("a full-size pane still gets the artwork's natural size",
+ logo_cells(999) == logo_cells(), f"{logo_cells(999)}")
+ c.check("and the image was actually placed each time", len(placed) >= 3,
+ f"{placed}")
+ finally:
+ kittygfx.supported, kittygfx.upload, kittygfx.place = (
+ real_supported, real_upload, real_place)
+ # Every later scenario assumes the suite's own geometry.
+ await c.pilot.resize_terminal(140, 44)
+ await c.pause(0.05)
+
+
+@scenario("modal_centering")
+async def s_modal_centering(c: Ctx):
+ """Every dialog we define is centred, without anyone maintaining a list.
+
+ The CSS used to name the screens that centre, so a new palette shipped
+ pinned to the top of the screen (twice). The rule is on ModalScreen now;
+ this fails if a modal ever opts out by accident, which the naked eye only
+ catches when the dialog is already in front of a user.
+ """
+ from textual.screen import ModalScreen
+
+ import idatui.app as A
+
+ ours = sorted(
+ (n for n, v in vars(A).items()
+ if isinstance(v, type) and issubclass(v, ModalScreen)
+ and v is not ModalScreen and v.__module__ == A.__name__),
+ key=str)
+ c.check("found the app's modal screens", len(ours) >= 8, f"{ours}")
+ styles = A.IdaTui.CSS
+ c.check("centring is a rule about modals, not a list of them",
+ "ModalScreen { align: center middle; }" in styles,
+ "the ModalScreen rule is gone")
+ # And prove it REACHES a dialog, rather than just being present in the text.
+ await c.press("ctrl+f")
+ opened = await c.wait(lambda: isinstance(c.app.screen, A.SearchPalette), 10)
+ if not opened:
+ c.check("the search palette opened", False,
+ f"screen={type(c.app.screen).__name__}")
+ return
+ scr = c.app.screen
+ await c.wait(lambda: scr.query_one("#pal-box").region.height > 0, 5)
+ box = scr.query_one("#pal-box").region
+ above, below = box.y, c.app.size.height - (box.y + box.height)
+ c.check("the search palette is vertically centred",
+ box.height > 0 and abs(above - below) <= 1,
+ f"box={box} screen={c.app.size} above={above} below={below}")
+ left = box.x
+ right = c.app.size.width - (box.x + box.width)
+ c.check("and horizontally centred", abs(left - right) <= 1,
+ f"left={left} right={right}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(c.app.screen, A.SearchPalette), 5)
+
+
+@scenario("db_search")
+async def s_db_search(c: Ctx):
+ """Ctrl+F: search the whole database, by disassembly text or by bytes."""
+ app = c.app
+ await c.open("main", "listing")
+ await c.press("ctrl+f")
+ opened = await c.wait(lambda: isinstance(app.screen, SearchPalette), 10)
+ c.check("Ctrl+F opens the search palette", opened,
+ f"screen={type(app.screen).__name__}")
+ if not opened:
+ return
+ pal = app.screen
+ inp = pal.query_one("#pal-input", Input)
+
+ # -- text: a mnemonic every x86-64 function starts with ------------------ #
+ inp.value = "endbr64"
+ await c.press("enter")
+ await c.wait(lambda: bool(pal._hits), 30)
+ c.check("a text search finds instructions", len(pal._hits) > 1,
+ f"n={len(pal._hits)}")
+ c.check("and it was classified as text",
+ pal._searched and pal._searched[0] == "text", f"{pal._searched}")
+ c.check("hits carry the line they matched",
+ all("endbr64" in h.line for h in pal._hits[:5]),
+ [h.line for h in pal._hits[:3]])
+
+ # -- text with padding: match what is SEEN, not IDA's column spacing ----- #
+ inp.value = "call cs:"
+ await c.press("enter")
+ found = await c.wait(lambda: pal._searched == ("text", "call cs:"), 30)
+ c.check("a query spanning IDA's column padding still matches",
+ found and len(pal._hits) > 0, f"n={len(pal._hits)}")
+
+ # -- bytes: the same endbr64, as a pattern ------------------------------- #
+ inp.value = "f3 0f 1e fa"
+ await c.press("enter")
+ await c.wait(lambda: pal._searched and pal._searched[0] == "bytes", 30)
+ c.check("a hex query is classified as bytes",
+ pal._searched and pal._searched[0] == "bytes", f"{pal._searched}")
+ c.check("and finds the same instruction", len(pal._hits) > 1,
+ f"n={len(pal._hits)}")
+
+ # -- wildcards ----------------------------------------------------------- #
+ inp.value = "f3 0f ?? fa"
+ await c.press("enter")
+ await c.wait(lambda: pal._searched == ("bytes", "f3 0f ?? fa"), 30)
+ c.check("a wildcard byte matches", len(pal._hits) > 1, f"n={len(pal._hits)}")
+
+ # -- a bad pattern must SAY so, not answer "no matches" ------------------ #
+ inp.value = "48 zz c3"
+ await c.press("enter")
+ await c.pause(0.1)
+ title = str(app.screen.query_one("#pal-box").border_title)
+ c.check("a malformed byte pattern is refused with a reason",
+ "not a byte" in title, f"title={title!r}")
+
+ # -- F2 pins the mode against the guess ---------------------------------- #
+ inp.value = "dead"
+ await c.pause(0.05)
+ c.check("a hex-looking WORD still searches text",
+ pal._mode_query()[0] == "text", f"{pal._mode_query()}")
+ await c.press("f2")
+ c.check("F2 forces it to bytes", pal._mode_query()[0] == "bytes",
+ f"{pal._mode_query()}")
+
+ # -- Enter on a result navigates ----------------------------------------- #
+ inp.value = "endbr64"
+ await c.press("f2") # back to text
+ await c.press("enter")
+ await c.wait(lambda: bool(pal._hits) and pal._searched
+ and pal._searched[0] == "text", 30)
+ target = pal._hits[1] if len(pal._hits) > 1 else pal._hits[0]
+ pal.query_one(OptionList).highlighted = 1 if len(pal._hits) > 1 else 0
+ await c.press("enter")
+ closed = await c.wait(lambda: not isinstance(app.screen, SearchPalette), 10)
+ c.check("Enter on a hit closes the palette", closed,
+ f"screen={type(app.screen).__name__}")
+ landed = await c.wait(
+ lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30)
+ c.check("and lands the cursor on it", landed,
+ f"cursor={c.lst._cursor_ea()} want={target.head:#x}")
+
+
+@scenario("export_findings")
+async def s_export_findings(c: Ctx):
+ """Ctrl+E writes a markdown report of what this session worked out.
+
+ Deliberately end-to-end: the interesting failure is not the formatting (that
+ is covered offline in test_findings.py) but whether a comment and a rename
+ made through the UI come back out of the database and into the file.
+ """
+ import tempfile
+
+ from idatui.findings import default_path
+
+ app = c.app
+ fn = c.biggest()
+ tag = os.getpid()
+ newname, note = f"exp_{tag}", f"found_it_{tag}"
+ old = fn.name
+ await c.open(fn.addr, "listing")
+
+ # Make something to find: a rename and a comment, through the real paths.
+ app.program.client.invoke(
+ "rename", batch={"func": {"addr": hex(fn.addr), "name": newname}})
+ app.program.bump_names()
+ app.program.set_comment(fn.addr, note)
+ app.program.invalidate(fn.addr)
+ # ...and tell the journal, exactly as the edit controller would. The
+ # database cannot say who wrote a comment (IDA's own analyzer uses the same
+ # call), so the journal is what makes this MY finding rather than noise.
+ app.journal.record("rename", fn.addr, f"{old} → {newname}")
+ app.journal.record("comment", fn.addr, note)
+
+ out = os.path.join(tempfile.gettempdir(), f"idatui-findings-{tag}.md")
+ try:
+ await c.press("ctrl+e")
+ inp = app.query_one("#export", Input)
+ opened = await c.wait(lambda: inp.display, 5)
+ c.check("Ctrl+E opens the export prompt", opened, f"display={inp.display}")
+ c.check("the prompt is prefilled with a path beside the binary",
+ inp.value == default_path(app._open_path), f"value={inp.value!r}")
+ inp.value = out
+ await c.press("enter")
+ written = await c.wait(lambda: os.path.exists(out), 30)
+ c.check("Enter writes the report", written, f"no {out}")
+ if not written:
+ return
+ doc = open(out, encoding="utf-8").read()
+ c.check("the report is markdown with the expected sections",
+ doc.startswith("# Findings") and "## Comments" in doc
+ and "## Named functions" in doc, doc[:60])
+ c.check("a comment written this session is in it", note in doc,
+ doc[:200])
+ c.check("and the function it belongs to is named", newname in doc,
+ doc[:200])
+ c.check("the report is sourced from the journal, not a scan",
+ "idatui's edit journal" in doc,
+ [l for l in doc.splitlines() if "**source**" in l])
+ c.check("the analyzer's own comments stay out of it",
+ "switch jump" not in doc and "jumptable" not in doc,
+ [l for l in doc.splitlines() if "switch" in l][:2])
+ c.check("the status line says where it went",
+ out in c.status(), c.status())
+ # The journal has to survive the database, or a report is only ever
+ # about the session that happened to be open.
+ from idatui.journal import Journal
+
+ app.journal.flush(app.program)
+ reloaded = Journal()
+ reloaded.load(app.program)
+ c.check("the journal round-trips through the .i64",
+ fn.addr in reloaded.addresses(),
+ f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}")
+ finally:
+ # Idempotent: hand the database back exactly as we found it.
+ app.program.set_comment(fn.addr, "")
+ app.program.client.invoke(
+ "rename", batch={"func": {"addr": hex(fn.addr), "name": old}})
+ app.program.bump_names()
+ app.program.invalidate(fn.addr)
+ if os.path.exists(out):
+ os.remove(out)
+
+
+@scenario("struct_filter")
+async def s_struct_filter(c: Ctx):
+ app = c.app
+ await c.press("ctrl+t")
+ if not await c.wait(lambda: isinstance(app.screen, StructEditor), 10):
+ c.check("Ctrl+T opens the struct editor", False,
+ f"screen={type(app.screen).__name__}")
+ return
+ se = app.screen
+ await c.wait(lambda: bool(se._structs), 15)
+ total = len(se._structs)
+ ol = se.query_one("#se-list", OptionList)
+ inp = se.query_one("#se-filter", Input)
+ # Something past the first row, so a filter that "works" by doing nothing
+ # can't pass: its own name must survive and the others must not.
+ target = se._structs[min(2, total - 1)].name
+ q = "".join(ch for ch in target.lower() if ch.isalnum())[:3]
+
+ ol.focus()
+ await c.press("slash")
+ opened = await c.wait(lambda: inp.display and app.focused is inp, 5)
+ c.check("'/' from the list opens the struct filter", opened,
+ f"display={inp.display} focus={getattr(app.focused, 'id', None)}")
+ for ch in q:
+ await c.press(ch)
+ await c.wait(lambda: len(se._structs) < total, 5)
+ c.check("typing fuzzy-filters the struct list",
+ 0 < len(se._structs) < total and
+ any(s.name == target for s in se._structs),
+ f"q={q!r} {len(se._structs)}/{total}")
+ cap = str(se.query_one("#se-title", Static).render())
+ c.check("the caption counts what the filter kept",
+ f"{len(se._structs)}/{total}" in cap, f"caption={cap!r}")
+
+ # 'd' is the delete binding on this screen: in the prompt it must be a
+ # character, not a destructive verb aimed at the highlighted struct.
+ await c.press("d")
+ await c.pause(0.05)
+ c.check("'d' in the filter types instead of deleting",
+ isinstance(app.screen, StructEditor) and inp.value == q + "d",
+ f"screen={type(app.screen).__name__} value={inp.value!r}")
+ await c.press("backspace")
+ await c.wait(lambda: inp.value == q, 5)
+
+ # Arrows drive the list while the prompt keeps focus (symbol-palette feel).
+ before = ol.highlighted
+ await c.press("down")
+ await c.pause(0.05)
+ c.check("arrows move the list while the filter has focus",
+ app.focused is inp and (ol.highlighted != before
+ or ol.option_count == 1),
+ f"{before} -> {ol.highlighted} of {ol.option_count}")
+
+ sel = se._structs[ol.highlighted or 0].name
+ ta = se.query_one(TextArea)
+ ta.text = ""
+ await c.press("enter")
+ loaded = await c.wait(lambda: sel in ta.text, 15)
+ c.check("Enter in the filter loads the highlighted struct", loaded,
+ f"want {sel!r} in {ta.text[:40]!r}")
+
+ # Esc backs out one level at a time: definition -> filter -> dialog.
+ await c.press("escape")
+ await c.wait(lambda: app.focused is ol, 5)
+ c.check("Esc leaves the definition for the list", app.focused is ol,
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("escape")
+ cleared = await c.wait(lambda: len(se._structs) == total, 5)
+ c.check("Esc clears the filter instead of closing",
+ cleared and not inp.display and isinstance(app.screen, StructEditor),
+ f"n={len(se._structs)}/{total} display={inp.display}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, StructEditor), 10)
+ c.check("a third Esc closes the editor",
+ not isinstance(app.screen, StructEditor),
+ f"screen={type(app.screen).__name__}")
+
+
@scenario("open_default_view")
async def s_open(c: Ctx):
app = c.app
@@ -1146,7 +1755,9 @@ async def s_search(c: Ctx):
si = app.query_one("#search", Input)
status = app.query_one("#status", Static)
await c.press("slash")
- await c.pause(0.1)
+ # display flips synchronously; the REGION only exists once Textual has laid
+ # the prompt out, which is a frame, not a worker.
+ await c.wait(lambda: si.display and si.region.height >= 1, 5)
c.check("search bar visible, status hidden (no overlap)",
si.display and not status.display, f"si={si.display} status={status.display}")
c.check("search input owns the bottom row (nothing overlaps it)",
@@ -1172,22 +1783,23 @@ async def s_incr_filter(c: Ctx):
table.focus()
full = table.row_count
await c.press("slash")
- await c.pause(0.1)
for ch in "sub_":
await c.press(ch)
- await c.pause(0.05)
- await c.pause(0.1)
+ # The filter is DEBOUNCED (set_timer(0.08)) -- a timer, not a worker, so
+ # settling can't see it. Wait for the effect instead of guessing at the
+ # debounce: it returns the moment the rows are rebuilt.
+ await c.wait(lambda: 0 < table.row_count < full, 5)
c.check("filter narrows incrementally as you type",
0 < table.row_count < full, f"{table.row_count}/{full}")
cell = table.get_row_at(0)[1]
c.check("filter highlights matched substring in name",
isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell)))
await c.press("enter")
- await c.pause(0.1)
+ await c.wait(lambda: isinstance(app.focused, DataTable), 5)
c.check("Enter keeps filter + focuses table",
isinstance(app.focused, DataTable) and table.row_count < full)
await c.press("escape")
- await c.pause(0.15)
+ await c.wait(lambda: table.row_count == full, 5)
c.check("Esc on the list clears the filter", table.row_count == full,
f"{table.row_count}/{full}")
@@ -1209,11 +1821,16 @@ async def s_follow_xrefs(c: Ctx):
orig_name = app._cur.name
depth = len(app._nav)
await c.press("enter")
- await c.wait(lambda: len(app._nav) > depth, 25)
+ # Wait for exactly what the check asserts. Waiting only on the nav depth let
+ # the check run while _cur was still the function we jumped FROM -- the
+ # follow pushes the source entry before it opens the target -- so this
+ # failed about one run in ten with cur == orig, at full speed, looking like
+ # a code regression.
+ await c.wait(lambda: len(app._nav) > depth and app._cur.ea != orig, 25)
c.check("Enter follows the call into another function",
app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}")
await c.press("escape")
- await c.pause(0.1)
+ await c.wait(lambda: app._cur.ea == orig, 15)
c.check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}")
dis.cursor = call_idx
dis.refresh()
@@ -1255,7 +1872,8 @@ async def s_follow_xrefs(c: Ctx):
await c.press("tab")
landed = await c.wait(
lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
- or (app._active == "listing" and "fail" in c.status().lower()), 25)
+ or (app.is_listing
+ and _CANNOT_DECOMP in c.status().lower()), 25)
if app._active == "decomp":
c.check("F5 at the xref site decompiles the referencing function",
dec.loaded_ea == xref.fn_addr, f"loaded={dec.loaded_ea}")
@@ -1427,14 +2045,14 @@ async def s_decomp_nav(c: Ctx):
old_ea = dec._line_ea(drow)
if old_ea is not None and dsym in old_line:
tmp = f"stale_{os.getpid()}"
- app.program.client.call("rename", batch={"func": {"addr": hex(dstale), "name": tmp}})
+ app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": tmp}})
app.program.bump_names()
d2 = len(app._nav)
app._follow_decomp(old_line, dsym, old_ea)
await c.wait(lambda: len(app._nav) > d2, 25)
c.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.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": dsym}})
app.program.bump_names()
@@ -1515,7 +2133,7 @@ async def s_rename(c: Ctx):
c.check("rename updates the function name",
app._func_index.by_addr(dtarget).name == newname,
app._func_index.by_addr(dtarget).name)
- rr = app.program.client.call("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}})
+ rr = app.program.client.invoke("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}})
c.check("rename reverted cleanly",
rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary")))
# goto label refuse
@@ -1561,11 +2179,15 @@ async def s_rename(c: Ctx):
f"display={ci.display}")
ci.value = cnote
await c.press("enter")
- await c.wait(lambda: dec.loaded_ea == app._cur.ea
- and any(cnote in t for t in dec._texts), 25)
+ # Gate on the comment showing up, and ONLY that: the extra
+ # `dec.loaded_ea == app._cur.ea` conjunct this used to carry is not a
+ # signal the re-decompile ever sets, so on some orderings the wait sat
+ # out its full 25s (9s of wall clock) and then the check below passed
+ # vacuously anyway.
+ await c.wait(lambda: any(cnote in t for t in dec._texts), 25)
c.check("comment appears in the pseudocode after ';'",
any(cnote in t for t in dec._texts), "comment not shown")
- app.program.client.call("set_comments", items=[{"addr": hex(cea), "comment": ""}])
+ app.program.client.invoke("set_comments", items=[{"addr": hex(cea), "comment": ""}])
else:
c.check("found a pseudocode line to comment", False, "no marker line")
@@ -1607,7 +2229,7 @@ async def s_comment_func(c: Ctx):
la is not None and lb is not None and lb > la
and a not in dec._texts[lb],
f"la={la} lb={lb}")
- app.program.client.call("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}])
+ app.program.client.invoke("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}])
@scenario("retype")
@@ -1771,7 +2393,10 @@ async def s_scroll_restore(c: Ctx):
await c.press("escape")
await c.wait(lambda: app._cur.ea == fa.addr, 20)
await c.wait(lambda: dis.total > 40, 20)
- await c.pause(0.25)
+ # A repaint is driven by Textual's frame timer, so settling does not imply
+ # one happened. Wait for the paint we are actually asserting about (each
+ # poll ticks the screen, so this is ~one frame, not a quarter second).
+ await c.wait(lambda: bool(renders) and renders[-1] == want_sy, 5)
c.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}) "
@@ -1872,7 +2497,7 @@ async def s_rename_history(c: Ctx):
await c.pause(0.15)
c.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}})
+ app.program.client.invoke("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
@scenario("region_define")
@@ -1952,8 +2577,12 @@ async def s_listing_view(c: Ctx):
return
await c.goto_ui(hex(data_ea))
+ # `total > 0` is set from the segment's size before a single page has
+ # materialised, so waiting on it and then reading rows was the suite's
+ # one known flake (it failed roughly one run in three). Wait for a ROW.
await c.wait(lambda: app._cur is not None and app._active == "listing"
- and c.lst.total > 0, 25)
+ and c.lst.total > 0 and c.lst.model is not None
+ and any(h.kind == "data" for h in c.lst.model.window(0, 40)), 25)
c.check("navigating to a data segment opens the listing view",
app._active == "listing" and c.lst.display and c.lst.total > 0,
f"active={app._active} total={c.lst.total}")
@@ -2091,7 +2720,7 @@ async def s_listing_name_addr(c: Ctx):
finally:
# revert: drop the label and restore raw bytes at A
try:
- c.prog.client.call("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
+ c.prog.client.invoke("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
except Exception: # noqa: BLE001
pass
c.prog.undefine(A, size=8)
@@ -2168,7 +2797,7 @@ async def s_listing_struct_expand(c: Ctx):
c.check("found a data address for the struct test", False)
return
try:
- c.prog.client.call(
+ c.prog.client.invoke(
"declare_type",
decls=["struct TuiExpandS { int a; char b[4]; short c; };"])
c.prog.make_data(A, "TuiExpandS")
@@ -2230,7 +2859,8 @@ async def s_continuous_view(c: Ctx):
c.lst.focus()
await c.press("tab")
await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
- or (app._active == "listing" and "fail" in c.status().lower()), 25)
+ or (app.is_listing
+ and _CANNOT_DECOMP in c.status().lower()), 25)
if app._active == "decomp":
c.check("F5/Tab decompiles the function under the cursor",
c.dec.loaded_ea == fn_ea, f"loaded={c.dec.loaded_ea}")
@@ -2269,28 +2899,828 @@ async def s_func_banners(c: Ctx):
c.check("the proc header names the function", hdr is not None, f"fn={fn.name}")
-# --------------------------------------------------------------------------- #
-# Runner
-# --------------------------------------------------------------------------- #
-async def _build_pristine(binary, cache):
- """Analyse ``binary`` once and keep the resulting database as a golden copy.
+def _find_literal(c, start=0, limit=150):
+ """(head, show) for a listing row at/after ``start`` whose literal is worth
+ reformatting: a value over 9, so hex and decimal actually LOOK different.
- Costs one full analysis, then every later run starts from it instead of
- re-analysing.
+ Scans FORWARD FROM THE CURSOR rather than from row 0: the listing is
+ continuous over the whole segment, so row 0 is nowhere near the function
+ that was opened.
"""
- print(f" (building pristine database for {os.path.basename(binary)}\u2026)")
- app = IdaTui(open_path=binary, keepalive=False)
- async with app.run_test(size=(140, 44)) as pilot:
- for _ in range(6000):
- await pilot.pause(0.05)
- if app._func_index is not None and app._func_index.complete:
+ for i in range(start, min(start + limit, len(c.lst.model))):
+ h = c.lst.model.get(i)
+ if h is None or h.kind != "code":
+ continue
+ try:
+ show = c.prog.op_format(h.ea, mode="show")
+ except Exception: # noqa: BLE001 -- no literal on this line
+ continue
+ v = show.get("value")
+ if v and int(v, 16) > 9 and {"hex", "dec"} <= set(show.get("choices", [])):
+ return h, show
+ return None
+
+
+async def _park_on(c, ea, tries=25):
+ """Put the listing cursor on ``ea`` and make sure it STAYS there.
+
+ An open that is still settling lands its own cursor when its worker
+ finishes, which silently moves a cursor a test set by hand — and then the
+ keypress under test edits somewhere else entirely.
+ """
+ held = 0
+ for _ in range(tries):
+ if c.lst._cursor_ea() == ea:
+ held += 1
+ if held >= 3:
+ return True
+ else:
+ held = 0
+ i = c.lst.model.index_of_ea(ea)
+ if i >= 0:
+ c.lst.cursor = i
+ c.lst.cursor_x = c.lst._insn_col(i)
+ await c.pause(0.1)
+ return False
+
+
+@scenario("opfmt_listing")
+async def s_opfmt_listing(c: Ctx):
+ """'o' cycles how the literal under the cursor is DISPLAYED (IDA's 'o'):
+ hex -> dec -> bin -> ... -> default, 'O' the other way, and the listing
+ re-renders in place."""
+ app = c.app
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+ found = _find_literal(c, start=c.lst.cursor)
+ if found is None:
+ c.check("found a listing literal to reformat", False)
+ return
+ head, show = found
+ ea = head.ea
+ c.lst.focus()
+ parked = await _park_on(c, ea)
+ c.check("the cursor is on the literal's line", parked,
+ f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}")
+ before = head.text
+ try:
+ await c.press("o")
+ await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ != before, 25)
+ i = c.lst.model.index_of_ea(ea)
+ after = c.lst.model.get(i).text if i >= 0 else before
+ c.check("'o' re-renders the literal", after != before,
+ f"{before!r} -> {after!r} ea={ea:#x} show={show}")
+ c.check("the status names the format it moved to",
+ any(f in c.status() for f in show["choices"]),
+ f"status={c.status()!r} choices={show['choices']}")
+ c.check("the change is marked unsaved", app._dirty)
+ # 'O' walks the ring the other way: back to where we started.
+ await c.press("O")
+ await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ == before, 25)
+ j = c.lst.model.index_of_ea(ea)
+ c.check("'O' cycles back", j >= 0 and c.lst.model.get(j).text == before,
+ f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}")
+ # An explicit format by name (what the palette/RPC use).
+ r = c.prog.op_format(ea, mode="dec")
+ c.check("an explicit format renders decimal",
+ r["format"] == "dec" and str(int(show["value"], 16)) in r["text"],
+ str(r))
+ finally:
+ try:
+ c.prog.op_format(ea, mode="default", n=show.get("n", -1))
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+@scenario("opfmt_no_literal")
+async def s_opfmt_no_literal(c: Ctx):
+ """A line with nothing to reformat says so instead of picking something."""
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+
+ def _banner(i):
+ h = c.lst.model.get(i)
+ return h is not None and h.kind in ("sep", "funchdr", "label")
+
+ row = next((i for i in range(c.lst.cursor, min(c.lst.cursor + 400,
+ len(c.lst.model)))
+ if _banner(i)), None)
+ if row is None:
+ c.check("found a banner row", False)
+ return
+ c.lst.focus()
+ for _ in range(20): # hold it against a late-landing open
+ c.lst.cursor = row
+ await c.pause(0.05)
+ if c.lst.cursor == row:
+ break
+ c.check("the cursor is on a banner row", _banner(c.lst.cursor),
+ f"row={c.lst.cursor}")
+ await c.press("o")
+ await c.wait(lambda: "reformat" in c.status() or "format" in c.status(), 15)
+ c.check("'o' on a line with no literal explains itself",
+ "reformat" in c.status(), f"status={c.status()!r}")
+
+
+@scenario("opfmt_refusal_is_not_swallowed")
+async def s_opfmt_refusal_visible(c: Ctx):
+ """A refusal right after a successful format still reaches the status bar.
+
+ A result written without priority loses to the PREVIOUS result's flash for
+ 8 seconds, so a refusal left the last success on the bar — and the RPC
+ snapshot reads that same bar, so a driver saw text that looked like the
+ edit had worked.
+
+ Driven the way the RPC verb drives it: the action called directly, with no
+ keystroke. A keypress clears the flash on its way in, which is why pressing
+ 'o' hides this bug entirely and only a driver ever saw it.
+ """
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+ found = _find_literal(c, start=c.lst.cursor)
+ if found is None:
+ c.check("found a listing literal to reformat", False)
+ return
+ head, show = found
+ c.lst.focus()
+ if not await _park_on(c, head.ea):
+ c.check("the cursor is on the literal's line", False)
+ return
+ try:
+ await c.press("o") # a success: sets the flash
+ await c.wait(lambda: "\u2192" in c.status(), 25)
+ good = c.status()
+
+ # ... and, within the flash window, 'o' where there is nothing to do.
+ # The edit rebuilt the segment model, so this is a different (empty)
+ # one than the load_all above filled.
+ c.lst.model.load_all()
+
+ def _banner(i):
+ h = c.lst.model.get(i)
+ return h is not None and h.kind in ("sep", "funchdr", "label")
+
+ row = next((i for i in range(c.lst.cursor,
+ min(c.lst.cursor + 400, len(c.lst.model)))
+ if _banner(i)), None)
+ if row is None:
+ c.check("found a banner row to refuse on", False)
+ return
+ for _ in range(20):
+ c.lst.cursor = row
+ await c.pause(0.05)
+ if c.lst.cursor == row:
break
- app.program.client.call("idb_save", timeout=600.0)
- db = binary + ".i64"
- if os.path.exists(db):
- shutil.copy2(db, cache)
+ c.lst.action_op_format("cycle") # no keypress: as the RPC does it
+ await c.wait(lambda: c.status() != good, 15)
+ c.check("a refusal replaces the previous success on the status bar",
+ c.status() != good and "reformat" in c.status(),
+ f"still showing {c.status()!r}")
+ finally:
+ try:
+ c.prog.op_format(head.ea, mode="default", n=show.get("n", -1))
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+def _styled_cols(strip, style_attr, want):
+ """Columns of ``strip`` whose rendered style has ``style_attr`` == want."""
+ cols, x = [], 0
+ for seg in strip:
+ st = seg.style
+ if st is not None and getattr(st, style_attr, None) is not None \
+ and str(getattr(st, style_attr)) == want:
+ cols.extend(range(x, x + len(seg.text)))
+ x += len(seg.text)
+ return cols
+
+
+@scenario("opfmt_highlight")
+async def s_opfmt_highlight(c: Ctx):
+ """The literal 'o' would reformat is MARKED on screen before you press it.
+
+ A line can carry several literals and the cursor picks one; without showing
+ which, you find out by pressing and reading the status. The marker must also
+ survive the cursor-line decoration, which paints the word under the cursor
+ (usually the same characters) and used to win.
+ """
+ from idatui.app import _S_OPERAND
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+ lst = c.lst
+ lst.focus()
+ # A row with two operands, so "which one" is a real question.
+ row = next((i for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model)))
+ if lst.model.get(i) is not None
+ and (lst.model.get(i).ops or ()) and len(lst.model.get(i).ops) >= 2),
+ None)
+ if row is None:
+ c.check("found a row with two operands", False)
+ return
+ h = lst.model.get(row)
+ if not await _park_on(c, h.ea):
+ c.check("parked on the two-operand row", False)
+ return
+ row = lst.model.index_of_ea(h.ea)
+ h = lst.model.get(row)
+ base = lst._insn_col(row)
+ want_bg = str(_S_OPERAND.bgcolor)
+ seen = []
+ for lo, hi, n in h.ops:
+ lst.cursor_x = base + lo
+ await c.pause(0.05)
+ strip = lst.render_line(row - round(lst.scroll_offset.y))
+ cols = _styled_cols(strip, "bgcolor", want_bg)
+ seen.append((n, min(cols) if cols else None, max(cols) + 1 if cols else None))
+ c.check(f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it",
+ cols and min(cols) == base + lo and max(cols) + 1 == base + hi,
+ f"marked={min(cols) if cols else None}.."
+ f"{max(cols)+1 if cols else None} want={base+lo}..{base+hi}")
+ c.check("the mark MOVES between the operands (it isn't the whole line)",
+ len({s[1] for s in seen}) == len(seen), str(seen))
+ # ... and the marked operand is the one the edit acts on — either it gets
+ # reformatted, or the refusal names that same operand. What must never
+ # happen is a different operand quietly changing.
+ for lo, hi, n in h.ops:
+ lst.cursor_x = base + lo
+ try:
+ r = c.prog.op_format(h.ea, mode="show", col=lst.op_col())
+ got, why = r.get("n"), ""
+ except IDAToolError as e: # "operand N (rsp) has no format"
+ m = re.search(r"operand (\d+)", e.message)
+ got, why = (int(m.group(1)) if m else None), e.message
+ c.check(f"marked operand {n} is the one acted on (or refused)",
+ got == n, f"marked op{n}, worker said op{got} {why}")
+
+
+@scenario("opfmt_sticks_to_its_literal")
+async def s_opfmt_sticks(c: Ctx):
+ """Pressing 'o' twice cycles the SAME literal, even when the line reflows.
+
+ A reformat changes the printed width (``48`` -> ``0x30``), which moves every
+ literal to its right. Holding the cursor column meant the second press
+ landed on a different literal — you cycle one number and a neighbour
+ changes.
+ """
+ app = c.app
+ pick = None
+ for fn in c.all_funcs()[:60]:
+ d = c.prog.decompile(fn.addr)
+ if d.failed or not d.code:
+ continue
+ nums = c.prog.pc_nums(fn.addr)
+ for line, recs in sorted(nums.items()):
+ # The second literal must be one whose printed WIDTH changes as it
+ # cycles (0x36u vs 54), or the cursor never falls off it and the
+ # test proves nothing.
+ if len(recs) >= 2 and recs[0][1] < recs[1][0] \
+ and int(recs[1][2], 16) >= 16:
+ pick = (fn, line, recs)
+ break
+ if pick:
+ break
+ if pick is None:
+ c.check("found a pseudocode line with two literals", False)
+ return
+ fn, line, recs = pick
+ first, second = recs[0], recs[1]
+ target = (second[3], second[4]) # (ea, opnum) of the literal we mean
+ other = (first[3], first[4])
+ try:
+ # Make it WIDE first (0x30, four characters). The cursor then sits on a
+ # column that stops existing when the literal is printed short (48) --
+ # which is the whole failure: the next press finds no literal under the
+ # cursor and quietly falls back to the first one on the line.
+ c.prog.pc_num_format(fn.addr, mode="hex", line=line, col=second[0])
+ c.prog.bump_names()
+ await c.open(fn.addr, "decomp")
+ if app._active != "decomp":
+ c.check("pseudocode view opened", False, f"active={app._active}")
+ return
+ dec = c.dec
+ dec.focus()
+ wide = next((r for r in dec._nums.get(line, ())
+ if (r[3], r[4]) == target), None)
+ if wide is None or wide[1] - wide[0] < 3:
+ c.check("the literal is now printed wide", False,
+ f"nums={dec._nums.get(line)}")
+ return
+ dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character
+ dec.refresh()
+ await c.pause(0.05)
+ seen = []
+ for _ in range(3):
+ before = dec._texts[line]
+ await c.press("o")
+ await c.wait(lambda: dec.loaded_ea == fn.addr
+ and line < len(dec._texts)
+ and dec._texts[line] != before, 30)
+ cur = next(((r[3], r[4]) for r in dec._nums.get(line, ())
+ if r[0] <= dec.cursor_x < r[1]), None)
+ seen.append(cur)
+ c.check("every press stays on the literal we started on",
+ all(s == target for s in seen),
+ f"target={target} other={other} landed={seen} "
+ f"line={dec._texts[line].strip()!r}")
+ finally:
+ try:
+ c.prog.pc_num_format(fn.addr, mode="default", line=line,
+ col=second[0])
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+@scenario("cursor_on_stays_visible")
+async def s_cursor_on_visible(c: Ctx):
+ """``cursor_on`` lands somewhere you can SEE, near where you are.
+
+ It used to scan from row 0 of the whole segment and move the cursor without
+ scrolling, so `drive fmt dec 18h` in main reformatted an ``18h`` 170 rows
+ away, off screen: the driver reported success and the pane showed a line
+ that hadn't changed.
+ """
+ from idatui.rpc import cursor_on
+ app = c.app
+ fn = await c.open_biggest("listing")
+ c.lst.model.load_all()
+ lst = c.lst
+ lst.focus()
+ await c.pause(0.1)
+ top = round(lst.scroll_offset.y)
+ # A token that occurs both before the viewport and inside it.
+ here = next((t for t in ("rax", "rsp", "eax", "rbp", "rdi")
+ if any(t in (lst._line_plain(i) or "")
+ for i in range(top, min(top + 20, lst.total)))
+ and any(t in (lst._line_plain(i) or "") for i in range(0, top))),
+ None)
+ if here is None:
+ c.check("found a token both above and inside the viewport", False,
+ f"top={top}")
+ return
+ found = cursor_on(app, here)
+ await c.pause(0.1)
+ c.check(f"cursor_on({here!r}) found it", found)
+ vis = round(lst.scroll_offset.y)
+ c.check("it lands inside the viewport, not thousands of rows above",
+ vis <= lst.cursor < vis + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}")
+ c.check("and it searched from the viewport, not from row 0",
+ lst.cursor >= top, f"cursor={lst.cursor} was top={top}")
+ # An explicit line still wins, and lands visibly.
+ far = next((i for i in range(0, min(top, lst.total))
+ if here in (lst._line_plain(i) or "")), None)
+ if far is not None:
+ cursor_on(app, here, line=far)
+ await c.pause(0.1)
+ v2 = round(lst.scroll_offset.y)
+ c.check("an explicit line is honoured AND scrolled into view",
+ lst.cursor == far and v2 <= far < v2 + lst._visible_height(),
+ f"cursor={lst.cursor} want={far} viewport={v2}")
+ # The `cursor` verb has the same duty: a driver that parks the cursor for
+ # an edit must leave it where the edit can be watched.
+ from idatui.rpc import place_cursor
+ deep = min(lst.total - 1, 900)
+ place_cursor(lst, deep, 0)
+ await c.pause(0.1)
+ v3 = round(lst.scroll_offset.y)
+ c.check("`cursor line=` scrolls to what it selected",
+ v3 <= deep < v3 + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}")
+
+
+@scenario("opfmt_decomp")
+async def s_opfmt_decomp(c: Ctx):
+ """'o' in the pseudocode reformats the C literal under the cursor. Hex-Rays
+ keeps its own number formats, so this is a different edit from the
+ listing's — and the decompilation re-renders."""
+ app = c.app
+ pick = None
+ for fn in c.all_funcs()[:60]:
+ d = c.prog.decompile(fn.addr)
+ if d.failed or not d.code:
+ continue
+ for i, txt in enumerate(d.code.split("\n")):
+ m = re.search(r"[=<>+\-*/(,]\s(\d{2,}|0x[0-9A-Fa-f]{2,})\b", txt)
+ if m and "//" not in txt[:m.start()]:
+ pick = (fn, i, m.start(1))
+ break
+ if pick:
+ break
+ if pick is None:
+ c.check("found a pseudocode number to reformat", False)
+ return
+ fn, line, col = pick
+ await c.open(fn.addr, "decomp")
+ if app._active != "decomp":
+ c.check("pseudocode view opened", False, f"active={app._active}")
+ return
+ dec = c.dec
+ dec.focus()
+ dec.cursor, dec.cursor_x = line, col
+ dec.refresh()
+ await c.pause(0.05)
+ before = dec._texts[line]
+ try:
+ await c.press("o")
+ await c.wait(lambda: dec.loaded_ea == fn.addr and line < len(dec._texts)
+ and dec._texts[line] != before, 30)
+ c.check("'o' re-renders the pseudocode literal",
+ line < len(dec._texts) and dec._texts[line] != before,
+ f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}")
+ c.check("the status says which format",
+ any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")),
+ f"status={c.status()!r}")
+ finally:
+ try:
+ c.prog.pc_num_format(fn.addr, mode="default", line=line, col=col)
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+@scenario("opfmt_opcode_key_moved")
+async def s_opfmt_opcode_key_moved(c: Ctx):
+ """'o' now belongs to the operand format, so the opcode-bytes column moved
+ to 'B' — and still cycles off/limited/full."""
+ await c.open_biggest("listing")
+ lst = c.lst
+ lst.focus()
+ await c.pause(0.05)
+ modes = [lst._op_mode]
+ for _ in range(3):
+ await c.press("B")
+ await c.pause(0.05)
+ modes.append(lst._op_mode)
+ c.check("'B' cycles the opcode-bytes column", len(set(modes)) == 3, str(modes))
+ c.check("and returns to where it started", modes[0] == modes[3], str(modes))
+
+# --------------------------------------------------------------------------- #
+# Graph view
+# --------------------------------------------------------------------------- #
+async def _open_graph(c: Ctx, fn=None, t=60):
+ """Open a multi-block function and press Space. Returns (fn, GraphView)."""
+ app = c.app
+ if fn is None:
+ # A function with real branching -- a straight-line one proves nothing
+ # about layering, and a huge one is slow.
+ fn = c.find_func(lambda f: 0x200 < f.size < 0x600) or c.biggest()
+ await c.open(fn.addr, "listing")
+ c.lst.focus()
+ await c.pause(0.05)
+ await c.press("space")
+ gv = app.query_one(GraphView)
+ ok = await c.wait(lambda: app._active == "graph" and gv.lay is not None, t)
+ c.check("the graph opened", ok,
+ f"active={app._active} sticky={app._graph_sticky} "
+ f"focus={type(app.focused).__name__} prompt={app._prompt_active()} "
+ f"status={c.status()!r}")
+ return fn, gv
+
+
+@scenario("graph_open")
+async def s_graph_open(c: Ctx):
+ app = c.app
+ fn, gv = await _open_graph(c)
+ c.check("space opens the graph view", app._active == "graph",
+ f"active={app._active} status={c.status()}")
+ if gv.lay is None:
+ return
+ c.check("the graph has the function's blocks",
+ len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1,
+ f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}")
+ c.check("it is the right function", gv.fc is not None and gv.fc.func_ea == fn.addr,
+ f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}")
+ c.check("the cursor starts on a real address", gv._cursor_ea() is not None)
+ # The invariant the whole dummy-node machinery exists for.
+ boxes = [(n.x, n.y, n.right, n.y + n.h - 1) for n in gv.lay.nodes]
+ overlap = any(a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
+ for i, a in enumerate(boxes) for b in boxes[i + 1:])
+ c.check("no two blocks overlap", not overlap)
+ c.check("the status names the graph", "graph" in c.status(), c.status())
+ await c.press("space")
+ await c.wait(lambda: app._active != "graph", 15)
+ c.check("space returns to the listing", app._active == "listing",
+ f"active={app._active}")
+
+
+@scenario("graph_nav")
+async def s_graph_nav(c: Ctx):
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None or len(gv.lay.nodes) < 2:
+ c.check("graph has enough blocks to navigate", False)
+ return
+ start_ea = gv._cursor_ea()
+ await c.press("j")
+ await c.pause(0.05)
+ c.check("j moves the cursor within the block", gv._cursor_ea() != start_ea,
+ f"{start_ea:#x} -> {gv._cursor_ea():#x}")
+ await c.press("k")
+ await c.pause(0.05)
+ c.check("k comes back", gv._cursor_ea() == start_ea)
+ # J follows an edge to a successor block
+ b0 = gv.cursor_node
+ succs = gv.lay.succ.get(b0) or []
+ if succs:
+ await c.press("J")
+ await c.pause(0.1)
+ c.check("J follows an edge to a successor block",
+ gv.cursor_node == succs[0][0],
+ f"node={gv.cursor_node} want={succs[0][0]}")
+ await c.press("K")
+ await c.pause(0.1)
+ c.check("K goes back up an edge", gv.cursor_node == b0,
+ f"node={gv.cursor_node} want={b0}")
+ await c.press("0")
+ await c.pause(0.1)
+ c.check("0 returns to the entry block", gv.cursor_node == gv.fc.entry,
+ f"node={gv.cursor_node} entry={gv.fc.entry}")
+ # the cursor is always scrolled into view
+ cell = gv._cursor_cell()
+ top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x)
+ c.check("the cursor block is scrolled into view",
+ cell is not None and top <= cell[0] < top + gv.size.height
+ and left <= cell[1] < left + gv.size.width,
+ f"cell={cell} scroll=({top},{left}) size={gv.size}")
+
+
+@scenario("graph_zoom")
+async def s_graph_zoom(c: Ctx):
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ full_h = gv.lay.height
+ seen = [gv.ZOOMS[gv._zoom]]
+ for _ in range(2):
+ await c.press("z")
+ await c.pause(0.15)
+ seen.append(gv.ZOOMS[gv._zoom])
+ c.check("z cycles the three zoom levels", seen == ["full", "compact", "collapsed"],
+ str(seen))
+ c.check("collapsed is much smaller than full", gv.lay.height < full_h,
+ f"{gv.lay.height} vs {full_h}")
+ c.check("the cursor survives a zoom", gv._cursor_ea() is not None)
+ c.check("the status still names the function",
+ gv.fc.name in c.status(), c.status())
+ await c.press("z")
+ await c.pause(0.15)
+ c.check("and wraps back to full", gv.ZOOMS[gv._zoom] == "full")
+ c.check("canvas is restored", gv.lay.height == full_h,
+ f"{gv.lay.height} vs {full_h}")
+
+
+@scenario("graph_render")
+async def s_graph_render(c: Ctx):
+ """The drawing itself: boxes, instruction text and edge glyphs must actually
+ reach the screen. A layout that is right but paints nothing looks identical
+ to a broken one from the outside."""
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ # The layout being ready (`gv.lay`) is not the same as the view having a
+ # SIZE to render into -- that needs a laid-out frame, and reading glyphs
+ # before one lands scrapes an empty canvas. Gate on the paint itself.
+ def _blob():
+ return "\n".join(gv.render_line(y).text for y in range(gv.size.height))
+
+ await c.wait(lambda: gv.size.height > 0 and "\u250c" in _blob(), 10)
+ blob = _blob()
+ c.check("boxes are drawn", blob.count("\u250c") >= 1 and blob.count("\u2502") > 4,
+ f"corners={blob.count(chr(0x250c))} verts={blob.count(chr(0x2502))}")
+ c.check("edges are drawn", any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"),
+ "no edge glyphs on screen")
+ ea = gv._cursor_ea()
+ head = gv.cur_head()
+ c.check("the cursor block's instruction text is on screen",
+ head is not None and head.text.split(" ")[0] in blob,
+ f"mnem={head.text.split(' ')[0] if head else None}")
+ c.check("the address gutter renders at full zoom",
+ ea is not None and f"{ea:08X}" in blob, f"ea={ea:#x}")
+ # minimap on/off actually changes the picture
+ before = blob
+ await c.press("m")
+ await c.wait(lambda: _blob() != before, 5)
+ after = _blob()
+ c.check("m toggles the minimap", after != before and not gv._show_minimap)
+ await c.press("m")
+ await c.wait(lambda: gv._show_minimap, 5)
+ c.check("and toggles it back", gv._show_minimap)
+ # a row query must never paint inside a box (that is what dummies buy us)
+ bad = 0
+ for row in range(min(gv.lay.height, 300)):
+ cells = gv.lay.painting.cells_at_row(row, 0, gv.lay.width)
+ if not cells:
+ continue
+ for n in gv.lay.nodes_at_row(row):
+ bad += sum(1 for col in cells if n.inside(row, col))
+ c.check("no edge is painted inside a block", bad == 0, f"{bad} cells")
+
+
+@scenario("graph_click")
+async def s_graph_click(c: Ctx):
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None or len(gv.lay.nodes) < 2:
+ c.check("graph has blocks to click", False)
+ return
+ # find a block whose body is on screen right now
+ top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x)
+ target = None
+ for n in gv.lay.nodes:
+ if (n.id != gv.cursor_node and top <= n.y + 1 < top + gv.size.height - 1
+ and left <= n.x + 2 < left + gv.size.width - 2):
+ target = n
+ break
+ if target is None:
+ c.check("a second block is visible to click", True, "(skipped: none on screen)")
+ return
+ PAD = 1 # GraphView { padding: 0 1 }
+ await c.pilot.click(GraphView,
+ offset=(PAD + target.x + 2 - left, target.y + 1 - top))
+ await c.pause(0.15)
+ c.check("clicking a block moves the cursor into it",
+ gv.cursor_node == target.id,
+ f"node={gv.cursor_node} want={target.id}")
+
+
+@scenario("graph_minimap")
+async def s_graph_minimap(c: Ctx):
+ """The minimap floats over the canvas, so it must be hit-tested BEFORE the
+ canvas -- otherwise a click on it reads as canvas coordinates and drops the
+ cursor into whatever block happens to lie underneath."""
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ # The minimap's rect is derived from the view's SIZE, so it needs a laid-out
+ # frame -- not just a settled app.
+ await c.wait(lambda: gv._minimap_rect() is not None, 5)
+ rect = gv._minimap_rect()
+ c.check("the minimap has a hit-box while it's shown", rect is not None,
+ f"size={gv.size} shown={gv._show_minimap}")
+ if rect is None:
+ return
+ left, top, mw, mh = rect
+ c.check("it sits inside the pane, clear of the scrollbar",
+ left + mw <= gv.size.width - 1,
+ f"left={left} w={mw} pane={gv.size.width}")
+
+ # a big graph, so the overview actually maps to somewhere far away
+ big = c.find_func(lambda f: f.size > 0x300) or fn
+ # _open_graph left graph mode STICKY, and a sticky navigation schedules the
+ # next function's graph by itself -- so whether the Space below ENTERS the
+ # graph or LEAVES it depended on whether that async load landed first. This
+ # scenario is about the minimap, not about sticky mode (graph_sticky covers
+ # that), so drop stickiness and press Space from a known state. Without
+ # this the whole scenario passes or fails on a coin toss: make the backend
+ # fast enough that the reload wins and every minimap click lands on a
+ # widget that is no longer on screen.
+ app._graph_sticky = False
+ await c.open(big.addr, "listing")
+ await c.wait(lambda: app._active == "listing", 10)
+ c.lst.focus()
+ await c.press("space")
+ await c.wait(lambda: app._active == "graph" and gv.lay is not None, 60)
+ if gv.lay is None or gv.lay.height < gv.size.height * 2:
+ c.check("a graph tall enough to scrub", True, "(skipped: too small)")
+ return
+
+ gv.scroll_to(y=0, x=0, animate=False)
+ await c.pause(0.1)
+ # click near the BOTTOM of the minimap -> the view should jump down
+ PAD = 1
+ await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + mh - 2))
+ await c.pause(0.2)
+ c.check("clicking low on the minimap scrolls the view down",
+ gv.scroll_offset.y > 0, f"scroll_y={gv.scroll_offset.y}")
+ # Most of a graph is padding, so a coordinate-accurate jump would park you
+ # in empty space with the cursor left behind: every minimap click must land
+ # on a block and take the cursor with it.
+ landed = gv.lay.by_id.get(gv.cursor_node)
+ c.check("it snaps the cursor onto a real block",
+ landed is not None and landed.block is not None,
+ f"node={gv.cursor_node}")
+ c.check("and that block is what the viewport is showing",
+ landed is not None
+ and int(gv.scroll_offset.y) <= landed.y + landed.h
+ and landed.y <= int(gv.scroll_offset.y) + gv.size.height,
+ f"node.y={landed.y if landed else None} "
+ f"scroll={gv.scroll_offset.y} h={gv.size.height}")
+ low_node = gv.cursor_node
+
+ # and the top of the minimap brings it back to a block up there
+ await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + 1))
+ await c.pause(0.2)
+ top_node = gv.lay.by_id.get(gv.cursor_node)
+ c.check("clicking high on the minimap goes back up",
+ top_node is not None and gv.cursor_node != low_node
+ and top_node.y < gv.lay.by_id[low_node].y,
+ f"top={gv.cursor_node} low={low_node}")
+ c.check("the cursor still has a real address after a minimap jump",
+ gv._cursor_ea() is not None)
+
+ # with the minimap hidden the same click is an ordinary canvas click
+ await c.press("m")
+ await c.pause(0.1)
+ c.check("no hit-box once it's hidden", gv._minimap_rect() is None)
+ await c.press("m")
+ await c.pause(0.1)
+
+ # Panning into the padding (which is most of the canvas) must not strand
+ # you on a blank screen with nothing to navigate back by.
+ gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0),
+ animate=False)
+ await c.pause(0.1)
+ c.check("a pan past the graph leaves the viewport empty",
+ not gv._viewport_has_block() or True) # setup, not an assertion
+ gv._snap_into_view()
+ await c.pause(0.1)
+ c.check("panning into empty padding snaps back to a block",
+ gv._viewport_has_block(),
+ f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}")
+
+
+@scenario("graph_rename")
+async def s_graph_rename(c: Ctx):
+ """Editing verbs must work from inside a box -- that is the whole point of
+ reusing the listing's rows rather than rendering our own."""
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ ea = gv._cursor_ea()
+ if ea is None:
+ c.check("cursor has an address", False)
+ return
+ new = f"gtest_{os.getpid()}"
+ await c.press("n")
+ await c.wait(lambda: app.query_one("#rename", Input).display, 10)
+ c.check("n opens the rename prompt from the graph",
+ app.query_one("#rename", Input).display)
+ inp = app.query_one("#rename", Input)
+ inp.value = ""
+ await c.type(new)
+ await c.press("enter")
+ await c.wait(lambda: not app.query_one("#rename", Input).display, 15)
+ await c.pause(0.4)
+ try:
+ got = app.program.resolve(new)
+ except Exception: # noqa: BLE001
+ got = None
+ c.check("the rename reached the database", got == ea,
+ f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}")
+ # revert, so the suite stays idempotent
+ if got is not None:
+ app.program.client.invoke(
+ "rename", batch={"data": {"addr": hex(ea), "new": ""}})
+ app.program.bump_names()
+
+
+@scenario("graph_sticky")
+async def s_graph_sticky(c: Ctx):
+ """Graph mode survives a navigation: jumping to another function should land
+ in ITS graph, not dump you back into the listing."""
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ other = c.find_func(lambda f: f.addr != fn.addr and 0x120 < f.size < 0x500)
+ if other is None:
+ c.check("a second function exists", True, "(skipped)")
+ return
+ app._goto(other.name)
+ ok = await c.wait(lambda: app._cur is not None and app._cur.ea == other.addr
+ and app._active == "graph"
+ and gv.fc is not None and gv.fc.func_ea == other.addr, 60)
+ c.check("a goto from the graph lands in the next function's graph", ok,
+ f"active={app._active} sticky={app._graph_sticky} "
+ f"cur={app._cur.ea if app._cur else None} "
+ f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}")
+ await c.press("space")
+ await c.wait(lambda: app._active != "graph", 15)
+ c.check("space still leaves graph mode", app._active == "listing",
+ f"active={app._active}")
+ c.check("and it stops being sticky", app._graph_sticky is False)
+
+
+# --------------------------------------------------------------------------- #
+# Runner
+# --------------------------------------------------------------------------- #
async def run(binary, only=None):
# The suite EDITS the database — it defines code, undefines items, renames
# and comments — and IDA saves those edits. Run that against the tracked
@@ -2302,20 +3732,13 @@ async def run(binary, only=None):
#
# So: work on a scratch copy, seeded from a golden database that nothing
# ever writes back to.
- with tempfile.TemporaryDirectory(prefix="idatui-pilot-") as scratch:
- target = os.path.join(scratch, os.path.basename(binary))
- shutil.copy2(binary, target)
- cache = binary + ".pristine.i64"
- if not (os.path.exists(cache)
- and os.path.getmtime(cache) >= os.path.getmtime(binary)):
- await _build_pristine(target, cache)
- if os.path.exists(cache):
- shutil.copy2(cache, target + ".i64")
+ async with staged(binary, lambda p: IdaTui(open_path=p, keepalive=False),
+ prefix="idatui-pilot-") as target:
await _run_on(target, only)
async def _run_on(binary, only=None):
- # Own idalib worker: opens the binary in-process over a unix socket.
+ # Code Mode attaches a registered GUI or starts/reuses a managed worker.
app = IdaTui(open_path=binary, keepalive=False)
async with app.run_test(size=(140, 44)) as pilot:
c = Ctx(app, pilot)
@@ -2335,6 +3758,14 @@ async def _run_on(binary, only=None):
print(f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED")
c.check("scenario did not crash", False, f"{type(e).__name__}: {e}")
traceback.print_exc()
+ # Headless run_test does not reliably emit App.Unmount; explicitly release
+ # the lease. Then wait through the managed worker's final-lease grace and
+ # IDB close so Windows can remove this suite's TemporaryDirectory safely.
+ if app.program is not None:
+ app.program.close()
+ if app.client is not None:
+ app.client.close()
+ await asyncio.to_thread(app.client.wait_released, 45.0)
def main(argv):
@@ -2349,6 +3780,8 @@ def main(argv):
STOP_AFTER = next(it)
elif a in ("--worker", "--binary"):
binary = os.path.abspath(os.path.expanduser(next(it)))
+ elif a == "--profile":
+ PROFILE.enabled = True
elif a == "--list":
for name, _ in SCENARIOS:
print(name)
@@ -2363,6 +3796,7 @@ def main(argv):
asyncio.run(run(binary, only))
except _StopSuite:
print(f" … stopped after '{STOP_AFTER}'")
+ PROFILE.report()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0