Files
manual_slop/conductor/tracks/test_engine_integration_20260627/spec.md
T
ed ca185235e9 conductor(track): init test_engine_integration_20260627 (Track 1 of 3)
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).
2026-06-26 23:43:56 -04:00

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_bundle v1.92.5 (pinned in pyproject.toml:7) ships the test engine compiled into the nanobind binary. Verified: from imgui_bundle import imgui; imgui.test_engine.TestEngine is a live class; imgui.test_engine.register_test, imgui.test_engine.queue_test, imgui.test_engine.get_result_summary, imgui.test_engine.TestContext with dock_into, window_focus, item_click, capture_screenshot_window, etc. are all present (verified via dir() enumeration — ~95 TestContext methods + ~35 module-level functions). The .pyi stub at .venv/Lib/site-packages/imgui_bundle/imgui/test_engine.pyi documents 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. When True, hello_imgui/immapp compiles 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=ON was 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 live TestEngine instance after use_imgui_test_engine = True. Verified callable.

  • RunnerCallbacks.register_tests: VoidFunction (.venv/Lib/site-packages/imgui_bundle/hello_imgui.pyi:1809) — the callback that hello_imgui invokes at startup to let the app register tests via imgui.test_engine.register_test(engine, group, name). The demo at .venv/Lib/site-packages/imgui_bundle/demos_python/demos_immapp/demo_testengine.py shows 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_init are set
    • src/gui_2.py:1486: immapp.run(app.runner_params, ...) — the main loop entry point
    • The GIL-transfer mechanism is built into immapp.run when use_imgui_test_engine = True; no additional threading code is needed on the Python side.
  • HookServer (src/api_hooks.py:857) — the HTTP server on 127.0.0.1:8999, started when --enable-test-hooks is passed. The do_GET method (line 157) and do_POST method (line 490) use a flat if/elif self.path == "/api/..." dispatch. The server holds self.app (the App instance) and accesses it via _get_app_attr(app, ...) helpers. The _pending_gui_tasks queue (app_controller.py:900) + _pending_gui_tasks_lock (app_controller.py:822) + _process_pending_gui_tasks() (app_controller.py:1844, called per-frame from gui_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 all live_gui tests.

  • live_gui fixture (tests/conftest.py:641) — session-scoped; spawns sloppy.py --enable-test-hooks --config=<temp> as a subprocess; polls http://127.0.0.1:8999/status until ready; yields a _LiveGuiHandle with .client (an ApiHookClient), .process, .workspace. The fixture's subprocess args are at conftest.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. The else branch at line 75 (the normal GUI mode) calls from src.gui_2 import main; main().

  • AppController.test_hooks_enabled (src/app_controller.py:1042) — set via "--enable-test-hooks" in sys.argv or SLOP_TEST_HOOKS=1 env 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_engine is never set to True. No callbacks.register_tests callback exists. The engine's scenario thread + GIL-transfer mechanism are dormant.

  • GAP-2: No /api/test_engine/* bridge endpoints. The HookServer has 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 ApiHookClient methods for test engine operations. The client has click, set_value, push_event, wait_for_event — but no queue_test, wait_for_test_results, get_test_results.

  • GAP-4: live_gui fixture doesn't pass --enable-test-engine. The subprocess at conftest.py:792 only 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.py accepts --enable-test-engine CLI flag; when set, App.run() sets runner_params.use_imgui_test_engine = True + assigns runner_params.callbacks.register_tests to a method that registers tests.

  • G2. App has a _register_imgui_tests(self) method (called by hello_imgui at startup via the register_tests callback) that registers at least one smoke test ("Smoke Tests", "Click Increment Button") via imgui.test_engine.register_test(engine, group, name). The smoke test's test_func(ctx) calls ctx.set_ref("...") + ctx.item_click("**/...") + CHECK(...).

  • G3. HookServer (in src/api_hooks.py) has 4 new endpoints:

    • POST /api/test_engine/queue — body {"group": "...", "name": "..."}; finds the test by group+name via imgui.test_engine.find_test_by_name(engine, group, name); calls queue_test(engine, test); responds {"status": "queued"}.
    • GET /api/test_engine/status — calls is_test_queue_empty(engine); responds {"queue_empty": true/false}.
    • GET /api/test_engine/results — calls get_result_summary(engine, out_results); responds {"count_tested": N, "count_success": N, "count_in_queue": N}.
    • POST /api/test_engine/abort — calls abort_current_test(engine); responds {"status": "aborted"}.
  • G4. ApiHookClient (in src/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 — polls get_test_status() until queue_empty == True or timeout; then returns get_test_results().
  • G5. The live_gui fixture passes --enable-test-engine in addition to --enable-test-hooks in the subprocess args (conftest.py:792). The engine activates on every live_gui test run.

  • G6. A smoke test in tests/test_test_engine_smoke.py that:

    1. Uses the live_gui fixture.
    2. Queues the smoke test via client.queue_test("Smoke Tests", "Click Increment Button").
    3. Polls via client.wait_for_test_results(timeout=30).
    4. Asserts results["count_success"] >= 1 and results["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.
  • G7. End-of-track report at docs/reports/TRACK_COMPLETION_test_engine_integration_20260627.md documenting: 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: add parser.add_argument("--enable-test-engine", action="store_true", help="Enable the Dear ImGui Test Engine for automated UI testing") alongside the existing --enable-test-hooks flag (line 35).
  • src/app_controller.py: add self.test_engine_enabled: bool = ("--enable-test-engine" in sys.argv) near line 1042 (same pattern as test_hooks_enabled).
  • src/gui_2.py App.run() (line 619): between the RunnerParams() construction (line 641) and the callbacks.show_gui = ... assignments (line 684), add:
    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
    
    This is guarded by the flag so normal runs are unaffected.

FR2: App._register_imgui_tests(self) method

  • New method on App in src/gui_2.py (near the other callback registrations, ~line 700):
    def _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_func
    
    The exact button + state to click + verify is determined during implementation by inspecting the running GUI's item tree (use ctx.window_info / imgui.show_id_stack_tool_window to 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.py do_POST (line 490): add 2 new elif branches for POST /api/test_engine/queue and POST /api/test_engine/abort.
  • In src/api_hooks.py do_GET (line 157): add 2 new elif branches for GET /api/test_engine/status and GET /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, but get_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: change gui_args to include "--enable-test-engine" when the fixture spawns the subprocess. The flag flows through to AppController.test_engine_enabledApp.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_func runs on the engine scenario thread. It must NOT directly mutate App or AppController state — it must use ctx.* primitives (which post simulated input to the GUI thread). Reading state via hello_imgui.get_imgui_test_engine() or engine queries (ctx.item_info, ctx.window_info) is safe. The CHECK() assertion runs on the scenario thread but only writes to the engine's result log (thread-safe C++ structure).
  • No live_gui regression: the --enable-test-engine flag must not affect normal GUI behavior when live_gui tests are NOT using the engine. The engine's scenario thread is idle when no tests are queued. The show_test_engine_windows panel is NOT shown by default (only via explicit call).
  • Performance: the engine adds a per-frame overhead when active. The fps_idling settings in runner_params remain unchanged. The engine's overhead is sub-millisecond per frame when no tests are running.

Architecture Reference

  • docs/guide_testing.md — the live_gui fixture, the structural testing contract, the Puppeteer pattern.
  • docs/guide_api_hooks.md — the Hook API surface, the /api/ask protocol, the ApiHookClient method reference.
  • docs/guide_gui_2.md — the App class lifecycle, the runner_params construction, the callbacks system.
  • .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: TestContext methods (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 — the CHECK(result: bool) assertion primitive.
  • conductor/workflow.md "Live_gui Test Fragility" + "Async Setters Need Poll-For-State" — the existing patterns for live_gui tests; the test engine's wait_for_test_results replaces time.sleep + get_value polling with a single engine-side poll.

Out of Scope

  • Migrating existing live_gui tests 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>). The ctx.capture_screenshot_window API 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_gui fixture already runs in CI via the batched runner. The --enable-test-engine flag is additive. No CI config changes needed.
  • Touching src/models.py or any taxonomy files. Zero overlap with the running tier2/post_module_taxonomy_de_cruft_20260627 branch or the enforcement_gap_closure_20260627 track.