aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_project_ui.py
blob: 731811dd75bdc69488147fb0669e128006f76433 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
#!/usr/bin/env python3
"""End-to-end pilot for project mode: two binaries, switching between them.

Needs idalib (it spawns real workers, one per binary) and textual:

    ~/ida-venv/bin/python tests/test_project_ui.py [bin1 bin2]

Defaults to targets/echo + targets/cat. The binaries are copied into a temp
source dir first, so the "source tree stays pristine" promise is checkable.
"""
import asyncio
import os
import shutil
import sys
import tempfile

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui._sync import wait_for  # noqa: E402
from idatui.app import IdaTui, ProjectPalette  # noqa: E402
from idatui.project import Project  # noqa: E402
from textual.widgets import Input, OptionList, Static  # noqa: E402

PASS = FAIL = 0


def check(name, cond, detail=""):
    global PASS, FAIL
    if cond:
        PASS += 1
        print(f"  ok   {name}")
    else:
        FAIL += 1
        print(f"  FAIL {name}   {detail}")


async def run(bins):
    repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "src")
        os.makedirs(src)
        srcs = []
        for b in bins:
            dst = os.path.join(src, os.path.basename(b))
            shutil.copy2(b, dst)
            srcs.append(dst)
        proj = Project.create(os.path.join(tmp, "proj.json"), srcs, name="proj")
        proj.stage_all()
        first, second = (r.label for r in proj.refs)

        app = IdaTui(keepalive=False, project=proj)
        async with app.run_test(size=(140, 44)) as pilot:
            async def settle(pred, t=180.0):
                return await wait_for(pred, pilot.pause, t, 0.05)

            # -- boots on the project's first binary ----------------------- #
            ok = await settle(lambda: app.program is not None
                              and app._func_index is not None
                              and app._func_index.complete)
            check("project mode boots on the first binary", ok,
                  f"binary={app._binary}")
            check("the active binary is the first one", app._binary == first,
                  f"{app._binary}")
            n_first = len(app._func_index)
            check("its functions loaded", n_first > 10, f"n={n_first}")
            status = str(app.query_one("#status", Static).render())
            check("the status line names the active binary",
                  f"[{first}]" in status, status[:60])

            # -- the switcher lists the project ---------------------------- #
            await pilot.press("ctrl+o")
            opened = await settle(
                lambda: isinstance(app.screen, ProjectPalette), 20)
            check("Ctrl+O opens the binary switcher", opened,
                  f"screen={type(app.screen).__name__}")
            if not opened:
                return
            pal = app.screen
            check("the switcher lists every project binary",
                  len(pal._results) == 2, f"{[e['label'] for e in pal._results]}")
            check("it marks which one is active",
                  any(e["active"] and e["label"] == first for e in pal._results))
            check("it marks the other as not yet opened",
                  any(not e["resident"] and e["label"] == second
                      for e in pal._results))
            ol = pal.query_one(OptionList)
            check("the switcher opens on the binary you're already in",
                  ol.highlighted is not None
                  and pal._results[ol.highlighted]["label"] == first,
                  f"highlighted={ol.highlighted} "
                  f"={pal._results[ol.highlighted]['label'] if ol.highlighted is not None else None} "
                  f"want={first}")

            # -- switch to the second binary -------------------------------- #
            pal.query_one(Input).value = second
            await pilot.pause(0.2)
            await pilot.press("enter")
            switched = await settle(
                lambda: app._binary == second and app.program is not None
                and app._func_index is not None and app._func_index.complete)
            check("switching opens the other binary", switched,
                  f"binary={app._binary}")
            check("the second binary has its own function index",
                  app._func_index is not None and len(app._func_index) > 5,
                  f"n={len(app._func_index) if app._func_index else 0}")
            check("both binaries now have live workers",
                  sorted(app._pool.resident()) == sorted([first, second]),
                  f"{app._pool.resident()}")
            landed = await settle(lambda: app._cur is not None, 60)
            check("it lands somewhere in the new binary", landed,
                  f"cur={app._cur}")
            where = app._cur.ea if app._cur else None

            # -- switch back: resident, so state is restored ---------------- #
            await pilot.press("ctrl+o")
            reopened = await settle(
                lambda: isinstance(app.screen, ProjectPalette), 20)
            check("the switcher reopens after a switch", reopened,
                  f"screen={type(app.screen).__name__}")
            if not reopened:
                return
            app.screen.query_one(Input).value = first
            await pilot.pause(0.2)
            await pilot.press("enter")
            back = await settle(lambda: app._binary == first
                                and app._func_index is not None
                                and app._func_index.complete, 120)
            check("switching back returns to the first binary", back,
                  f"binary={app._binary}")
            check("its function index came back intact",
                  app._func_index is not None and len(app._func_index) == n_first,
                  f"n={len(app._func_index) if app._func_index else 0} want={n_first}")

            # and forward again: the second binary's position was remembered
            await pilot.press("ctrl+o")
            if not await settle(lambda: isinstance(app.screen, ProjectPalette), 20):
                check("returning to a binary restores where you were", False,
                      "switcher did not reopen")
                return
            app.screen.query_one(Input).value = second
            await pilot.pause(0.2)
            await pilot.press("enter")
            again = await settle(lambda: app._binary == second
                                 and app._cur is not None, 120)
            check("returning to a binary restores where you were",
                  again and app._cur.ea == where,
                  f"cur={app._cur.ea if app._cur else None} want={where}")

            # -- project-wide symbol search ------------------------------- #
            # 'main' exists in BOTH binaries: identical name, so the rank tuple
            # ties and a bare sort() would fall through to comparing Hit objects
            # ('<' not supported between instances of 'Hit').
            from idatui.app import SymbolPalette
            await settle(lambda: app._index is not None
                         and len(app._index.counts()) == 2, 60)
            check("both binaries got indexed",
                  len(app._index.counts()) == 2, f"{app._index.counts()}")
            await pilot.press("ctrl+n")
            if await settle(lambda: isinstance(app.screen, SymbolPalette), 20):
                pal = app.screen
                pal.query_one(Input).value = "main"
                await pilot.pause(0.3)
                await pilot.press("f2")          # widen to the whole project
                await pilot.pause(0.4)
                names = [(b, n) for b, _, n in pal._results]
                check("project scope finds a name shared by both binaries",
                      len({b for b, n in names if n == "main"}) == 2,
                      f"{names[:6]}")
                await pilot.press("escape")
                await pilot.pause(0.2)

            # -- and the same toggle for strings --------------------------- #
            from idatui.app import StringsPalette
            await pilot.press("quotation_mark")
            if await settle(lambda: isinstance(app.screen, StringsPalette), 30):
                pal = app.screen
                pal.query_one(Input).value = "usage"
                await pilot.pause(0.3)
                local = {b for b, _, _ in pal._results}
                await pilot.press("f2")
                await pilot.pause(0.4)
                wide = {b for b, _, _ in pal._results}
                check("strings: local scope is this binary only", local == {None},
                      f"{local}")
                check("strings: F2 widens across the project",
                      len(wide) >= 2 and None not in wide, f"{wide}")
                await pilot.press("escape")
                await pilot.pause(0.2)

        # -- the promise: nothing was written next to the sources ---------- #
        left = sorted(os.listdir(src))
        check("the source tree stays pristine (no .i64/scratch beside it)",
              left == sorted(os.path.basename(s) for s in srcs), f"{left}")
        staged = sorted(os.listdir(proj.bin_dir))
        check("IDA's artifacts all live in the project sidecar",
              any(f.endswith(".i64") for f in staged), f"{staged}")

    print(f"\n{PASS} passed, {FAIL} failed")
    return 1 if FAIL else 0


def main(argv):
    repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    bins = argv or [os.path.join(repo, "targets", "echo"),
                    os.path.join(repo, "targets", "cat")]
    for b in bins:
        if not os.path.isfile(b):
            print(f"no such binary: {b}")
            return 2
    return asyncio.run(run(bins))


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))