Spec + plan + metadata + state for the ImGui Test Engine integration. Enables the test engine via --enable-test-engine flag, bridges it through the existing API hooks layer (4 new /api/test_engine/* endpoints + 4 new ApiHookClient methods), and proves the full bridge with a smoke test. The test engine enables high-fidelity simulation of docking, window focus, panel visibility, drag-and-drop, and keyboard input that the current Hook API cannot express. The API hooks remain the single communication boundary; the test engine is integrated behind it. This is Track 1 of a 3-track campaign: Track 1: bridge + smoke test (this track) Track 2: migrate docking/focus/panel tests Track 3: visual regression via screenshot capture Key risk: R1 (GIL-transfer crash) mitigated by Phase 1 Task 1.4 manual verification checkpoint. Parallel-safe against the running tier2 taxonomy branch and the enforcement_gap_closure track (zero file overlap).
19 KiB
Track Specification: ImGui Test Engine Integration (Bridge via API Hooks)
Overview
Integrate the Dear ImGui Test Engine (imgui_bundle.imgui.test_engine) into Manual Slop's test infrastructure to enable high-fidelity simulation of user interactions — docking, window focus, panel visibility, drag-and-drop, keyboard input — that the current Hook API cannot express.
The design principle: the API hooks layer (HookServer on :8999 + ApiHookClient) remains the single communication boundary between the test process (pytest) and the GUI subprocess. The test engine is integrated behind the API hooks, not alongside them. New /api/test_engine/* endpoints bridge the test process to the engine's queue_test / get_result_summary API. The engine's test_func closures run on the engine's scenario thread (GIL-transferred by hello_imgui/immapp); they use ctx.item_click("**/Label"), ctx.dock_into(src, dst, dir), ctx.window_focus(ref) etc. to post simulated input events to the GUI render thread. The existing _pending_gui_tasks queue and the engine's input simulation are two separate event injection paths into the same GUI thread; they compose without conflict.
This is Track 1 of 3 in the test engine campaign. Track 1 = enable the engine + build the bridge + smoke test. Track 2 (follow-up) = migrate docking/focus/panel tests. Track 3 (follow-up) = visual regression via screenshot capture.
Current State Audit (as of master 77b70226)
Already Implemented (DO NOT re-implement)
-
imgui_bundlev1.92.5 (pinned inpyproject.toml:7) ships the test engine compiled into the nanobind binary. Verified:from imgui_bundle import imgui; imgui.test_engine.TestEngineis a live class;imgui.test_engine.register_test,imgui.test_engine.queue_test,imgui.test_engine.get_result_summary,imgui.test_engine.TestContextwithdock_into,window_focus,item_click,capture_screenshot_window, etc. are all present (verified viadir()enumeration — ~95TestContextmethods + ~35 module-level functions). The.pyistub at.venv/Lib/site-packages/imgui_bundle/imgui/test_engine.pyidocuments the full API. -
hello_imgui.RunnerParams.use_imgui_test_engine: bool = False(.venv/Lib/site-packages/imgui_bundle/hello_imgui.pyi:2969) — the flag that enables the engine. WhenTrue,hello_imgui/immappcompiles the engine into the runner and provides the GIL-transfer mechanism for the scenario thread. The engine is already compiled into the wheel (the C++ build flag-DHELLOIMGUI_WITH_TEST_ENGINE=ONwas set for the published wheel); the Python-side flag just activates it. -
hello_imgui.get_imgui_test_engine()(.venv/Lib/site-packages/imgui_bundle/hello_imgui.pyi:3355) — returns the liveTestEngineinstance afteruse_imgui_test_engine = True. Verified callable. -
RunnerCallbacks.register_tests: VoidFunction(.venv/Lib/site-packages/imgui_bundle/hello_imgui.pyi:1809) — the callback thathello_imguiinvokes at startup to let the app register tests viaimgui.test_engine.register_test(engine, group, name). The demo at.venv/Lib/site-packages/imgui_bundle/demos_python/demos_immapp/demo_testengine.pyshows the full pattern. -
imgui_bundle.imgui.test_engine_checks.CHECK(result: bool)— the assertion primitive that emits pass/fail to the engine's result log with file:line traceback. Verified importable. -
The app already uses
hello_imgui.RunnerParams+immapp.run()— the exact integration path the test engine requires:src/gui_2.py:641:self.runner_params = _hi.RunnerParams()src/gui_2.py:684-688:self.runner_params.callbacks.show_gui/show_menus/load_additional_fonts/setup_imgui_style/post_initare setsrc/gui_2.py:1486:immapp.run(app.runner_params, ...)— the main loop entry point- The GIL-transfer mechanism is built into
immapp.runwhenuse_imgui_test_engine = True; no additional threading code is needed on the Python side.
-
HookServer(src/api_hooks.py:857) — the HTTP server on127.0.0.1:8999, started when--enable-test-hooksis passed. Thedo_GETmethod (line 157) anddo_POSTmethod (line 490) use a flatif/elif self.path == "/api/..."dispatch. The server holdsself.app(theAppinstance) and accesses it via_get_app_attr(app, ...)helpers. The_pending_gui_tasksqueue (app_controller.py:900) +_pending_gui_tasks_lock(app_controller.py:822) +_process_pending_gui_tasks()(app_controller.py:1844, called per-frame fromgui_2.py:1759) is the existing thread-safe command queue from HTTP handler thread → main render thread. -
ApiHookClient(src/api_hook_client.py) — the Python client with retry logic, health-check polling,wait_for_server(timeout),push_event(action, payload),get_value(item),set_value(item, value),click(item),wait_for_event(event_type, timeout), etc. Used by alllive_guitests. -
live_guifixture (tests/conftest.py:641) — session-scoped; spawnssloppy.py --enable-test-hooks --config=<temp>as a subprocess; pollshttp://127.0.0.1:8999/statusuntil ready; yields a_LiveGuiHandlewith.client(anApiHookClient),.process,.workspace. The fixture's subprocess args are atconftest.py:792:gui_args = ["uv", "run", "python", "-u", gui_script, "--enable-test-hooks"]. -
sloppy.py(79 lines) — the entry point. CLI flags at lines 31-36:--headless,--web-host,--web-port,--enable-test-hooks,--config. Theelsebranch at line 75 (the normal GUI mode) callsfrom src.gui_2 import main; main(). -
AppController.test_hooks_enabled(src/app_controller.py:1042) — set via"--enable-test-hooks" in sys.argvorSLOP_TEST_HOOKS=1env var. Same pattern works for--enable-test-engine.
Gaps to Fill (This Track's Scope)
-
GAP-1: The test engine is not enabled.
runner_params.use_imgui_test_engineis never set toTrue. Nocallbacks.register_testscallback exists. The engine's scenario thread + GIL-transfer mechanism are dormant. -
GAP-2: No
/api/test_engine/*bridge endpoints. TheHookServerhas no way for the test process to queue a test, poll results, or abort a running test. The test engine API (queue_test,get_result_summary,is_test_queue_empty,abort_current_test) is only accessible from inside the GUI process — not from the HTTP boundary. -
GAP-3: No
ApiHookClientmethods for test engine operations. The client hasclick,set_value,push_event,wait_for_event— but noqueue_test,wait_for_test_results,get_test_results. -
GAP-4:
live_guifixture doesn't pass--enable-test-engine. The subprocess atconftest.py:792only passes--enable-test-hooks. Without the engine flag, the engine won't activate even after GAP-1 is fixed. -
GAP-5: No smoke test proving the end-to-end threading model works. The test engine's scenario thread + GIL transfer is the highest-risk piece. A minimal smoke test (register a trivial test that clicks a known button + asserts a state change, queue it via the API, poll for results, assert pass) is needed to prove the bridge works before Track 2 migrates real tests.
Architecture: Why the API hooks + test engine compose
pytest test process
└── ApiHookClient (HTTP :8999) ← single communication boundary (KEPT)
└── HookServer.do_POST ← new /api/test_engine/* endpoints
└── imgui.test_engine.queue_test(engine, test) ← schedules on engine
└── TestContext.test_func(ctx) ← runs on engine scenario thread
└── ctx.item_click("**/Label") ← posts simulated input to GUI thread
└── GUI render thread processes the simulated event
└── _process_pending_gui_tasks() still runs per-frame
(existing queue; unaffected; two separate injection paths)
The test engine's test_func runs on its own thread (the scenario thread). The ctx.* primitives post simulated input events to the ImGui input queue on the GUI render thread. This is the same destination as real user input and the same destination as _pending_gui_tasks — but a different injection mechanism. The two paths are independent; they don't share state, locks, or queues. The test engine doesn't touch _pending_gui_tasks and vice versa.
The GIL-transfer caveat (documented at the top of test_engine.pyi) is handled by hello_imgui/immapp when use_imgui_test_engine = True — the C++ layer transfers the GIL between the main thread and the scenario thread. No additional Python-side threading code is needed. The test_func callback runs with the GIL held; it can safely call ctx.* primitives (which are C++ nanobind calls that release the GIL during the simulated input wait).
Goals
-
G1.
sloppy.pyaccepts--enable-test-engineCLI flag; when set,App.run()setsrunner_params.use_imgui_test_engine = True+ assignsrunner_params.callbacks.register_teststo a method that registers tests. -
G2.
Apphas a_register_imgui_tests(self)method (called byhello_imguiat startup via theregister_testscallback) that registers at least one smoke test ("Smoke Tests", "Click Increment Button") viaimgui.test_engine.register_test(engine, group, name). The smoke test'stest_func(ctx)callsctx.set_ref("...")+ctx.item_click("**/...")+CHECK(...). -
G3.
HookServer(insrc/api_hooks.py) has 4 new endpoints:POST /api/test_engine/queue— body{"group": "...", "name": "..."}; finds the test by group+name viaimgui.test_engine.find_test_by_name(engine, group, name); callsqueue_test(engine, test); responds{"status": "queued"}.GET /api/test_engine/status— callsis_test_queue_empty(engine); responds{"queue_empty": true/false}.GET /api/test_engine/results— callsget_result_summary(engine, out_results); responds{"count_tested": N, "count_success": N, "count_in_queue": N}.POST /api/test_engine/abort— callsabort_current_test(engine); responds{"status": "aborted"}.
-
G4.
ApiHookClient(insrc/api_hook_client.py) has 4 new methods:queue_test(group: str, name: str) -> dict— POST to/api/test_engine/queue.get_test_status() -> dict— GET/api/test_engine/status.get_test_results() -> dict— GET/api/test_engine/results.wait_for_test_results(timeout: float = 30.0) -> dict— pollsget_test_status()untilqueue_empty == Trueor timeout; then returnsget_test_results().
-
G5. The
live_guifixture passes--enable-test-enginein addition to--enable-test-hooksin the subprocess args (conftest.py:792). The engine activates on everylive_guitest run. -
G6. A smoke test in
tests/test_test_engine_smoke.pythat:- Uses the
live_guifixture. - Queues the smoke test via
client.queue_test("Smoke Tests", "Click Increment Button"). - Polls via
client.wait_for_test_results(timeout=30). - Asserts
results["count_success"] >= 1andresults["count_tested"] >= 1. This proves the full bridge works: pytest → HTTP → HookServer → engine → scenario thread →ctx.item_click→ GUI thread → state change →CHECK→ result log →get_result_summary→ HTTP → pytest assert.
- Uses the
-
G7. End-of-track report at
docs/reports/TRACK_COMPLETION_test_engine_integration_20260627.mddocumenting: what shipped, the threading model verification, any GIL-transfer issues encountered, and the handoff to Track 2 (docking test migration).
Functional Requirements
FR1: --enable-test-engine CLI flag
sloppy.py: addparser.add_argument("--enable-test-engine", action="store_true", help="Enable the Dear ImGui Test Engine for automated UI testing")alongside the existing--enable-test-hooksflag (line 35).src/app_controller.py: addself.test_engine_enabled: bool = ("--enable-test-engine" in sys.argv)near line 1042 (same pattern astest_hooks_enabled).src/gui_2.pyApp.run()(line 619): between theRunnerParams()construction (line 641) and thecallbacks.show_gui = ...assignments (line 684), add:This is guarded by the flag so normal runs are unaffected.if getattr(self.controller, "test_engine_enabled", False): self.runner_params.use_imgui_test_engine = True self.runner_params.callbacks.register_tests = self._register_imgui_tests
FR2: App._register_imgui_tests(self) method
- New method on
Appinsrc/gui_2.py(near the other callback registrations, ~line 700):The exact button + state to click + verify is determined during implementation by inspecting the running GUI's item tree (usedef _register_imgui_tests(self) -> None: """Called by hello_imgui at startup to register ImGui Test Engine tests. Reads the live engine via hello_imgui.get_imgui_test_engine(). [C: src/gui_2.py:App.run (via callbacks.register_tests)] """ from imgui_bundle import hello_imgui from imgui_bundle.imgui import test_engine engine = hello_imgui.get_imgui_test_engine() # Smoke test: click a known button and verify state change test = test_engine.register_test(engine, "Smoke Tests", "Click Increment Button") def smoke_func(ctx) -> None: from imgui_bundle.imgui.test_engine_checks import CHECK ctx.set_ref("...") # TODO: set to a known window ctx.item_click("**/...") # TODO: click a known button CHECK(True) # TODO: verify state change test.test_func = smoke_funcctx.window_info/imgui.show_id_stack_tool_windowto find labels). The smoke test should click something harmless (e.g., a tab switch, a checkbox toggle) and verify the state changed.
FR3: /api/test_engine/* endpoints in HookServer
- In
src/api_hooks.pydo_POST(line 490): add 2 newelifbranches forPOST /api/test_engine/queueandPOST /api/test_engine/abort. - In
src/api_hooks.pydo_GET(line 157): add 2 newelifbranches forGET /api/test_engine/statusandGET /api/test_engine/results. - All 4 endpoints guard on
test_engine_enabled— if the engine is not active, respond{"error": "test engine not enabled", "enabled": false}with HTTP 503. - The engine instance is obtained via
hello_imgui.get_imgui_test_engine()inside the handler (lazy import; the handler runs on the HTTP thread, butget_imgui_test_engine()is a C++ accessor that returns a pointer — safe to call from any thread).
FR4: ApiHookClient methods
- In
src/api_hook_client.py: add 4 methods per G4. Follow the existing method pattern (e.g.,get_status,push_event): construct the URL,requests.post/get, retry on connection error, parse JSON, return the dict.
FR5: live_gui fixture update
- In
tests/conftest.py:792: changegui_argsto include"--enable-test-engine"when the fixture spawns the subprocess. The flag flows through toAppController.test_engine_enabled→App.run()→runner_params.use_imgui_test_engine = True.
FR6: Smoke test
tests/test_test_engine_smoke.py(NEW) — 2-3 tests:test_engine_enabled:client.get_value("test_engine_enabled")returns True (or verify via a new gettable field).test_queue_and_run_smoke_test: queue the smoke test, poll for results, assert success.test_engine_results_shape:get_test_results()returns the expected dict shape.
Non-Functional Requirements
- 1-space indentation for all Python code.
- No comments in body per AGENTS.md.
- CRLF line endings preserved.
- Atomic per-task commits.
- Thread safety: the
test_funcruns on the engine scenario thread. It must NOT directly mutateApporAppControllerstate — it must usectx.*primitives (which post simulated input to the GUI thread). Reading state viahello_imgui.get_imgui_test_engine()or engine queries (ctx.item_info,ctx.window_info) is safe. TheCHECK()assertion runs on the scenario thread but only writes to the engine's result log (thread-safe C++ structure). - No
live_guiregression: the--enable-test-engineflag must not affect normal GUI behavior whenlive_guitests are NOT using the engine. The engine's scenario thread is idle when no tests are queued. Theshow_test_engine_windowspanel is NOT shown by default (only via explicit call). - Performance: the engine adds a per-frame overhead when active. The
fps_idlingsettings inrunner_paramsremain unchanged. The engine's overhead is sub-millisecond per frame when no tests are running.
Architecture Reference
docs/guide_testing.md— thelive_guifixture, the structural testing contract, the Puppeteer pattern.docs/guide_api_hooks.md— the Hook API surface, the/api/askprotocol, theApiHookClientmethod reference.docs/guide_gui_2.md— theAppclass lifecycle, therunner_paramsconstruction, thecallbackssystem..venv/Lib/site-packages/imgui_bundle/demos_python/demos_immapp/demo_testengine.py— the canonical demo for the test engine integration pattern (register_tests callback + test_func closures + CHECK)..venv/Lib/site-packages/imgui_bundle/imgui/test_engine.pyi— the full API stub (2644 lines). Key sections:TestContextmethods (lines 1445-2096), module-level functions (lines 433-500, 2639+),TestEngineResultSummary(3 fields: count_tested, count_success, count_in_queue)..venv/Lib/site-packages/imgui_bundle/imgui/test_engine_checks.py— theCHECK(result: bool)assertion primitive.conductor/workflow.md"Live_gui Test Fragility" + "Async Setters Need Poll-For-State" — the existing patterns forlive_guitests; the test engine'swait_for_test_resultsreplacestime.sleep+get_valuepolling with a single engine-side poll.
Out of Scope
- Migrating existing
live_guitests to the test engine. That's Track 2 (test_engine_docking_tests_<date>). This track only builds the bridge + proves it works with 1 smoke test. - Visual regression via screenshot capture. That's Track 3 (
test_engine_capture_regression_<date>). Thectx.capture_screenshot_windowAPI is available but not wired in this track. - Headless test execution (no GUI window). The test engine requires a live GLFW window (the scenario thread drives the actual ImGui render loop). Headless mode is a future research item, not this track.
- The test engine's interactive UI panel (
show_test_engine_windows). Not shown by default. Can be added as a debug toggle in a follow-up. - Test engine license audit. Per the stub: "free for individuals, educational, open-source, and small businesses. Paid for larger businesses." This project is personal-use; no audit needed. Flagged for awareness only.
- CI wiring of the test engine. The
live_guifixture already runs in CI via the batched runner. The--enable-test-engineflag is additive. No CI config changes needed. - Touching
src/models.pyor any taxonomy files. Zero overlap with the runningtier2/post_module_taxonomy_de_cruft_20260627branch or theenforcement_gap_closure_20260627track.