summaryrefslogtreecommitdiffstats
path: root/tests/_fixtures.py
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-06 23:25:23 +0200
committerblasty <blasty@local>2026-08-06 23:25:23 +0200
commit9622e957cdd9602a2f2230ed7d4c90b06688a217 (patch)
treed81a9c2761da67f2929d6fe852090a3f418038b1 /tests/_fixtures.py
parentsplit: stop the resync loop that spun the worker forever (diff)
downloadida-tui-9622e957cdd9602a2f2230ed7d4c90b06688a217.tar.gz
ida-tui-9622e957cdd9602a2f2230ed7d4c90b06688a217.tar.xz
ida-tui-9622e957cdd9602a2f2230ed7d4c90b06688a217.zip
tests: wait for the thing, don't sleep and hope
test_trace_ui spent 18.8 of its 35.2 seconds in flat pilot.pause() calls placed to let an async seek land. Two loops were most of it: 6 iterations at 0.5s and 28 at 0.3s, 11.4s of sleeping to check that a step moves the cursor. They are condition waits now. The questions are unchanged -- does the listing cursor reach the pc, does the pseudocode cursor follow -- but they cost what they cost instead of a fixed budget. The second loop settles on something that does NOT presuppose the answer (the listing cursor arriving, and the trail map belonging to the loaded function): waiting on 'is this pc mapped' would have burned the timeout on every unmapped instruction, about half of them, and come out slower than the sleep it replaced. 35.2s -> 20.9s, 39 checks, stable over repeated runs. tests/_fixtures.py collects the staging both this suite and test_scenarios need -- scratch copy, seeded from a golden .i64 nothing writes back to -- which was private to test_scenarios. Worth saying plainly: on targets/echo the seeding is worth 0.19s, not the analysis time I assumed when I went looking. It is shared for the deduplication and for whatever gets pointed at a bigger binary.
Diffstat (limited to 'tests/_fixtures.py')
-rw-r--r--tests/_fixtures.py89
1 files changed, 89 insertions, 0 deletions
diff --git a/tests/_fixtures.py b/tests/_fixtures.py
new file mode 100644
index 0000000..766ecd5
--- /dev/null
+++ b/tests/_fixtures.py
@@ -0,0 +1,89 @@
+"""Shared setup for the suites that need a real analysed binary.
+
+Two things every IDA suite has to get right, and only test_scenarios did:
+
+**Work on a scratch copy.** Opening a binary writes a `.i64` beside it, and the
+suites EDIT it -- they define code, undefine items, rename and comment, and IDA
+saves that. Run against the tracked target and each run inherits the last one's
+damage: a scenario started failing with no code change because an earlier one
+had undefined an instruction. A suite whose result depends on its own history
+can't be trusted to accuse the code.
+
+**Seed from a golden database.** Auto-analysis is the bulk of a suite's runtime
+(`targets/echo` is ~30s of it) and it produces the same answer every time. Doing
+it once and keeping the result as `<binary>.pristine.i64` -- which nothing ever
+writes back to -- turns that into a file copy.
+
+ with staged("targets/echo") as target:
+ app = IdaTui(open_path=target, ...)
+
+The cache is rebuilt whenever it is older than the binary, so editing a target
+doesn't silently test the previous one. `.pristine.i64` is gitignored.
+"""
+from __future__ import annotations
+
+import contextlib
+import os
+import shutil
+import tempfile
+
+
+def cache_path(binary: str) -> str:
+ return binary + ".pristine.i64"
+
+
+def cache_is_fresh(binary: str) -> bool:
+ c = cache_path(binary)
+ return os.path.exists(c) and os.path.getmtime(c) >= os.path.getmtime(binary)
+
+
+async def build_pristine(binary: str, cache: str, app_factory) -> None:
+ """Analyse ``binary`` once and keep the database as a golden copy.
+
+ ``app_factory(path)`` builds the IdaTui -- passed in so this module needs no
+ import of the app (and so a suite can hand over its own load options).
+ """
+ print(f" (building pristine database for {os.path.basename(binary)}\u2026)")
+ app = app_factory(binary)
+ async with app.run_test(size=(140, 44)) as pilot:
+ for _ in range(6000):
+ await pilot.pause(0.05)
+ if app._func_index is not None and app._func_index.complete:
+ break
+ app.program.client.call("idb_save", timeout=600.0)
+ db = binary + ".i64"
+ if os.path.exists(db):
+ shutil.copy2(db, cache)
+
+
+@contextlib.contextmanager
+def scratch_copy(binary: str, prefix: str = "idatui-test-"):
+ """A temp-dir copy of ``binary``, seeded from the pristine cache if there is
+ a fresh one. Yields the copy's path; the directory goes away after.
+
+ Does NOT build the cache (that needs an app and an event loop) -- a suite
+ that wants one calls :func:`build_pristine` first. Without a cache this is
+ still correct, just slow: the worker analyses from scratch.
+ """
+ with tempfile.TemporaryDirectory(prefix=prefix) as d:
+ target = os.path.join(d, os.path.basename(binary))
+ shutil.copy2(binary, target)
+ if cache_is_fresh(binary):
+ shutil.copy2(cache_path(binary), target + ".i64")
+ yield target
+
+
+@contextlib.asynccontextmanager
+async def staged(binary: str, app_factory=None, prefix: str = "idatui-test-"):
+ """:func:`scratch_copy`, building the pristine cache first if it's missing.
+
+ This is what a suite wants: one call, and the analysis is paid once ever
+ rather than once per run.
+ """
+ if app_factory is not None and not cache_is_fresh(binary):
+ with tempfile.TemporaryDirectory(prefix=prefix) as seed_dir:
+ seed = os.path.join(seed_dir, os.path.basename(binary))
+ shutil.copy2(binary, seed)
+ await build_pristine(seed, cache_path(binary), app_factory)
+ with scratch_copy(binary, prefix) as target:
+ yield target