4.1. Playwright — block-form test harness for daslang-live + dasImgui apps

Block-form test harness for daslang-live + dasImgui apps. Spawns a host process, configures a transport (HTTP by default), and runs a test block against the live registry — click a widget by path, set a value, await quiescence, snapshot the surface, expect a value or render state. The with_imgui_app(...) { ... } lifecycle handles launch / handshake / shutdown so each test stays a single block. The live-API port is derived from DEFAULT_LIVE_PORT + worker_index (dastest’s isolated-mode flag --worker-index N), so N parallel workers land on distinct ports and never collide.

Locators (find_widget, widget_exists, widget_payload_field, widget_rendered) resolve via the same path keys the boost registry uses; actions (click, type_text, force_set_value, drag, focus, open_widget, close_widget, reload) post to the live-command surface in imgui_boost_runtime and apply the matching AwaitModifiers. Polling helpers (wait_until, wait_for_payload_value, await_quiescent) wrap the timeout/retry loop so tests don’t reinvent it.

4.1.1. Constants

DEFAULT_LIVE_PORT = 9090

DEFAULT_LIVE_PORT:int const

DEFAULT_DEADLOCK_SANITY_SEC = 120f

DEFAULT_DEADLOCK_SANITY_SEC:float const

DEFAULT_READY_TIMEOUT_SEC = 30f

DEFAULT_READY_TIMEOUT_SEC:float const

MACOS_READY_TIMEOUT_SEC = 90f

MACOS_READY_TIMEOUT_SEC:float const

ATTACH_PROBE_SEC = 0.3f

ATTACH_PROBE_SEC:float const

DEFAULT_TEST_TIMEOUT_SEC = 120f

DEFAULT_TEST_TIMEOUT_SEC:float const

DEFAULT_RELOAD_TIMEOUT_SEC = 30f

DEFAULT_RELOAD_TIMEOUT_SEC:float const

DEFAULT_FRAME_WAIT_SEC = 10f

DEFAULT_FRAME_WAIT_SEC:float const

READY_POLL_INTERVAL_MS = 0x64

READY_POLL_INTERVAL_MS:uint const

READY_POLL_REQUEST_TIMEOUT_SEC = 2

READY_POLL_REQUEST_TIMEOUT_SEC:int const

FRAME_POLL_INTERVAL_MS = 0x64

FRAME_POLL_INTERVAL_MS:uint const

RECORD_FPS = 30

RECORD_FPS:int const

NARRATE_READ_MS = 0x1194

NARRATE_READ_MS:uint const

SETTLE_MS = 0x4b0

SETTLE_MS:uint const

CLICK_DWELL_MS = 0x5dc

CLICK_DWELL_MS:uint const

KEY_TAB = 512

KEY_TAB:int const

KEY_ENTER = 525

KEY_ENTER:int const

TYPE_LEAD_MIN_MS = 0x2bc

TYPE_LEAD_MIN_MS:uint const

TYPE_LEAD_MAX_MS = 0xbb8

TYPE_LEAD_MAX_MS:uint const

TYPE_PER_CHAR_S = 0.16f

TYPE_PER_CHAR_S:float const

TYPE_MIN_FIELD_S = 0.9f

TYPE_MIN_FIELD_S:float const

TYPE_FIELD_BEAT_MS = 0xb4

TYPE_FIELD_BEAT_MS:uint const

CLICK_RELEASE_MS = 130

CLICK_RELEASE_MS:int const

TYPE_EFFECT_FRAMES = 120

TYPE_EFFECT_FRAMES:int const

COMBO_LEAD_MIN_MS = 0x2bc

COMBO_LEAD_MIN_MS:uint const

COMBO_LEAD_MAX_MS = 0xbb8

COMBO_LEAD_MAX_MS:uint const

COMBO_OPEN_INSET = 40f

COMBO_OPEN_INSET:float const

COMBO_TRAVEL_MS = 450

COMBO_TRAVEL_MS:int const

COMBO_POLL_FRAMES = 120

COMBO_POLL_FRAMES:int const

TREE_LEAD_MIN_MS = 0x2bc

TREE_LEAD_MIN_MS:uint const

TREE_LEAD_MAX_MS = 0xbb8

TREE_LEAD_MAX_MS:uint const

TREE_ARROW_FRAC = 0.6f

TREE_ARROW_FRAC:float const

TREE_TRAVEL_MS = 450

TREE_TRAVEL_MS:int const

TREE_POLL_FRAMES = 120

TREE_POLL_FRAMES:int const

CLOSE_BTN_FRAC = 0.5f

CLOSE_BTN_FRAC:float const

RECORD_ASSET_REL = "doc/source/_static/tutorials"

RECORD_ASSET_REL:string const

4.1.2. Structures

ImguiApp

struct ImguiApp

VoEntry
Fields:
  • hash : string - < say_hash(spoken) — links a narration line to its wav

  • text : string - < on-screen caption (the say() text arg)

  • voice : string - < spoken line fed to TTS (say() voice arg, or text if none)

  • wav : string - < wav basename under the voiceover/ dir

  • dur_s : float - < measured wav duration, seconds

VoManifest

struct VoManifest

VoSidecarHit
Fields:
  • frame : int - < recorded APNG frame index at the caption’s appearance — the sync anchor

  • t_s : float - < wall-clock seconds from record_start (reference/debug; convert uses frame)

  • wav : string - < wav basename to mux

  • text : string - < narration line (for reference)

VoSidecar

struct VoSidecar

4.1.3. App lifecycle

4.1.3.1. with_imgui_app

with_imgui_app(feature_path: string; body: block<(app:ImguiApp):void> )

Spawn daslang-live <feature_path> with the dasImgui module loaded; runs body while alive, then POSTs /shutdown and drains stdout. Panics on non-zero exit code, ready-timeout, or test-timeout so the surrounding [test] fails. The common case — for a feature whose require chain needs ONLY dasImgui. Delegates to the 5-arg overload with no extra modules.

Arguments:
  • feature_path : string

  • body : block<(app: ImguiApp):void>

with_imgui_app(feature_path: string; extra_module_roots: array<string>; feature_root: string; feature_prefix: string; body: block<(app:ImguiApp):void> )

4.1.3.2. with_recording_app

with_recording_app(feature_path: string; output_apng_basename: string; max_seconds: int; body: block<(app:ImguiApp):void> )

Native-fps form (the common case). Delegates to the 4-arg overload — daslang’s gen2 trailing-block sugar can’t see through a default parameter sitting between the last positional and the block, so the overload pair keeps the common call site clean.

Arguments:
  • feature_path : string

  • output_apng_basename : string

  • max_seconds : int

  • body : block<(app: ImguiApp):void>

with_recording_app(feature_path: string; output_apng_basename: string; max_seconds: int; fps: int; body: block<(app:ImguiApp):void> )
with_recording_app(feature_path: string; output_apng_basename: string; max_seconds: int; fps: int; extra_module_roots: array<string>; feature_root: string; asset_root: string; body: block<(app:ImguiApp):void> )

4.1.4. Locators / queries

find_widget(snap: JsonValue?; ident: string ): JsonValue?

Navigate to snapshot.globals[ident]; may return a JV(null) placeholder for missing keys (daslib/json ?[] quirk). For an existence check use widget_exists.

Arguments:
get_text(app: ImguiApp; uri: string ): string

GET uri; return the raw response body or “” (bounded by the per-request timeout).

Arguments:
widget_click_point(snap: JsonValue?; ident: string ): tuple<float;float>

The point imgui_click aims at for ident: the left glyph (square check / radio circle) for the toggle family — checkbox / radio_button / radio_button_int — and the bbox center otherwise. Mirrors resolve_target_bbox in imgui_boost_runtime so a recording’s move_to lands exactly where the click fires (no mid-row → glyph hop). Keep the kind list in sync with that resolver.

Arguments:
widget_component_point(snap: JsonValue?; ident: string; component: int ): tuple<float;float>

Center of multi-component widget ident’s sub-rect component (slider_float2/3/4, slider_int2/3/4, drag_float2/3/4, drag_int2/3/4, drag_*_range2), read from the sub_bboxes telemetry — the deterministic way to target a single handle of a vector/range widget, instead of guessing an x within the combined bbox. Falls back to widget_click_point when sub_bboxes is absent (single-component widget, component out of range, or a host predating the field).

Arguments:
  • snap : JsonValue?

  • ident : string

  • component : int

widget_exists(snap: JsonValue?; ident: string ): bool

True iff globals[ident] is a real widget entry — works around the daslib/json ?[] quirk by checking the always-present kind field.

Arguments:
widget_payload_field(snap: JsonValue?; ident: string; field: string ): JsonValue?

Navigate to snapshot.globals[ident].payload[field]; returns null if any link is missing.

Arguments:
  • snap : JsonValue?

  • ident : string

  • field : string

widget_rendered(snap: JsonValue?; ident: string ): bool

True iff globals[ident] EXISTS and painted this frame. Registered-but-unpainted entries carry "rendered": false; painted widgets omit the field (?? true). The leading existence check is load-bearing: an absent (or misspelled) path yields null ?? true — a false pass — so a never-registered ident must report not-rendered, not silently “rendered”.

Arguments:

4.1.5. Actions

click(app: ImguiApp; target: string ): JsonValue?

Synthesize a left-click on target (imgui_click verb).

Arguments:
click_at(events: array<JsonValue?>; t_ms: int; pos: tuple<float;float>; travel_ms: int = 500; button: int = 0 )

Append lerp-from-last + press + release + dwell to events. Cursor travels from the tracked previous position to pos over travel_ms, then mouse button button is pressed and released. Updates the tracked position.

Arguments:
  • events : array< JsonValue?>

  • t_ms : int

  • pos : tuple<float;float>

  • travel_ms : int

  • button : int

close_widget(app: ImguiApp; target: string ): JsonValue?

Flip target.state.open = false (imgui_close verb) — closes a window/tree node from the test driver.

Arguments:
double_click(app: ImguiApp; target: string ): JsonValue?

Faithful double-click on target’s bbox center: two press/release taps so IsMouseDoubleClicked fires — e.g. enter-directory / open-file in a file dialog. Re-snapshots to resolve the center client-side (like drag); the target must be rendered. Frame-paced (t_ms = frame index, like drag): the host advances exactly one event per frame, so the two taps are guaranteed to land on distinct frames a couple of frames apart — well inside MouseDoubleClickTime at any frame rate, and never collapsed into one frame the way a wall-clock timeline can be on a slow/stalled host (which drops the double-click). Returns {ok=false, error} when target isn’t in the snapshot or isn’t rendered this frame (widget_click_point would otherwise default the bbox to (0,0) and click nowhere).

Arguments:
drag(app: ImguiApp; target: string; dx: float; dy: float; steps: int = 4; button: int = 0 ): JsonValue?

Synthesize a mouse-drag on target’s bbox center: warp → press → drag to center+(dx,dy) → release, composed as an imgui_mouse_play timeline. Resolves the bbox client-side from a fresh snapshot. Returns {ok, value=target} (ok=false when the target isn’t in the snapshot, isn’t rendered this frame, or the command post fails — a stale/unrendered widget would otherwise drag from a defaulted (0,0) bbox). steps scales the drag duration.

Arguments:
  • app : ImguiApp

  • target : string

  • dx : float

  • dy : float

  • steps : int

  • button : int

drag_along(events: array<JsonValue?>; t_ms: int; from_pos: tuple<float;float>; to_pos: tuple<float;float>; drag_ms: int; approach_ms: int = 400; button: int = 0 )

Append lerp-to-start + press + drag + release + dwell to events. Cursor first travels from the tracked previous position to from_pos over approach_ms, then presses and drags to to_pos over drag_ms. Updates the tracked position to to_pos.

Arguments:
  • events : array< JsonValue?>

  • t_ms : int

  • from_pos : tuple<float;float>

  • to_pos : tuple<float;float>

  • drag_ms : int

  • approach_ms : int

  • button : int

drag_to(app: ImguiApp; source: string; target: string; steps: int = 6; button: int = 0 ): JsonValue?

Drag from source widget center to target widget center. Reads both bboxes from a fresh snapshot, computes the delta, and dispatches the L1 drag coroutine (press at source → interpolated moves → release).

Arguments:
  • app : ImguiApp

  • source : string

  • target : string

  • steps : int

  • button : int

focus(app: ImguiApp; target: string ): JsonValue?

Force keyboard focus to the next render of target (imgui_focus verb); display-only widgets reply Result.err("not focusable").

Arguments:
force_set_value(app: ImguiApp; target: string; value: JsonValue? ): JsonValue?

Set target’s payload value remotely (imgui_force_set verb); value is the JSON form expected by the widget kind.

Arguments:
force_set_verified(app: ImguiApp; target: string; value: auto(T); max_frames: int = 120 ): auto

Recording-as-test force_set: set target.value to the typed value and verify it took. Accumulates a self-verification miss (surfaced as a loud panic at with_recording_app teardown) rather than panicking mid-body — so the recording still stops + shuts the host down cleanly. For force_set-driven tutorial stages (slider / drag / input / color); the verified counterpart of the clicks hold_through_voice checks automatically. Use only inside a with_recording_app body — raw tests want the panicking expect_value.

Arguments:
  • app : ImguiApp

  • target : string

  • value : auto(T)

  • max_frames : int

key_tap(app: ImguiApp; key: int; hold_frames: int = 2 )

Synthesize a discrete press+release of an ImGuiKey by integer code, FRAME-PACED: the key is held for hold_frames frames then released via imgui_key_play’s frame-index pacing, so it fires its shortcut/navigation exactly once regardless of frame rate. The old wall-clock press+release pair spanned a variable number of frames, so on a slow frame (e.g. the recorder’s synchronous-APNG capture) the hold crossed ImGui’s key-repeat threshold and over-fired — one Tab cycling several fields, one Enter committing twice. Mirrors imgui_key_chord’s frame-pacing but for any key (no ASCII / modifier restriction). Use int(ImGuiKey.Tab) / int(ImGuiKey.Enter) / etc. at the call site.

Arguments:
  • app : ImguiApp

  • key : int

  • hold_frames : int

move_to(app: ImguiApp; pos: tuple<float;float>; duration_ms: int = 600 )

Post a lerped cursor move from the last tracked position to pos, with a brief dwell pin afterwards. Updates the tracked position. duration_ms is a FLOOR: the actual duration scales with distance (≈0.325 px/ms ≈ constant cursor speed) so a long move doesn’t cover its span in the same time as a short one and read as a teleport; short moves keep the floor.

Arguments:
  • app : ImguiApp

  • pos : tuple<float;float>

  • duration_ms : int

open_widget(app: ImguiApp; target: string ): JsonValue?

Flip target.state.open = true (imgui_open verb) — reopens a window/tree node from the test driver.

Arguments:
reload(app: ImguiApp; timeout_sec: float = DEFAULT_RELOAD_TIMEOUT_SEC )

POST /reload then poll /status until has_error == false; panics on timeout. Stays on raw HTTP — reload is daslang-live process control, not a dasImgui command verb.

Arguments:
reset(app: ImguiApp; timeout_sec: float = DEFAULT_RELOAD_TIMEOUT_SEC )

POST /reset — re-simulate the already-compiled program into a fresh context (no parse / no infer), clearing @live state for a clean slate. ~100x cheaper than reload; use between subtests of one feature instead of respawning the host. Polls /status until the generation counter advances (a deterministic done signal). Panics if the host never reports a baseline generation (e.g. a daslang-live without POST /reset support), if a reset advanced generation but left has_error set, or on timeout.

Arguments:
reset_cursor_pos(pos: tuple<float;float> = tuple<tuple<float;float>>(0f,0f) )

Reset the tracked previous cursor position. Call between recordings if the host’s synth cursor state was already non-(0,0).

Arguments:
  • pos : tuple<float;float>

resize_window(app: ImguiApp; target: string; w: float; h: float ): JsonValue?

Resize the window named by target to (w, h) on the next frame. Routes through imgui_set_window_size (regular window container); the dispatcher feeds state.pending_size and window() calls SetNextWindowSize on the next frame. SetNextWindowSizeConstraints callbacks (snap/aspect/quantize) reshape the request — used by the window_size_constraints recording driver to demonstrate exactly that. Named resize_window (not set_window_size) to avoid the overload-resolution collision with imgui_containers_builtin’s in-frame set_window_size(size : float2; cond : ImGuiCond).

Arguments:
  • app : ImguiApp

  • target : string

  • w : float

  • h : float

right_click(app: ImguiApp; target: string ): JsonValue?

Synthesize a right-click on target (imgui_click verb with button = 1). Used by popup_context_item / popup_context_window drivers.

Arguments:
select_widget(app: ImguiApp; target: string ): JsonValue?

Activate target tab_item (imgui_select verb) — the automation counterpart of clicking a tab (ImGui owns active-tab selection, so an inactive tab can’t be reached by click).

Arguments:
type_text(app: ImguiApp; target: string; text: string; focus_max_frames: int = 120; duration_s: float = 0f ): JsonValue?

Auto-focus target, wait up to focus_max_frames for focus to settle, then stream text as synthetic key+char events via imgui_key_type (into whichever widget holds focus). Returns {ok, value=target} (ok=false on empty text or if focus never settles). duration_s > 0 paces typing over wall-clock; default uses imgui_key_type’s per-char cadence.

Arguments:
  • app : ImguiApp

  • target : string

  • text : string

  • focus_max_frames : int

  • duration_s : float

4.1.6. Recording / narration

click_render_voice(app: ImguiApp; dwell: uint; click_target: string; watch_target: string; expect_rendered: bool )

Real-click click_target UNDER an already-posted voice line, then assert watch_target’s rendered state == expect_rendered — the reveal/dismiss rail. expect_rendered=true is a REVEAL (a button that opens a popup/modal: watch a body widget appear); expect_rendered=false is a DISMISS (a Yes/No/Apply/menu-item that closes the popup: watch a body widget — often the click target itself — disappear). A button whose own click closes the popup can’t be verified by its click-count (it’s gone next frame); the watch-target’s render state is the durable signal. Self-verifying via record_check_rendered: a missed reveal/dismiss aborts the recording at teardown. The caller should move_to the click target first. Every hold is PACING; the reveal/dismiss is polled, not guessed.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • click_target : string

  • watch_target : string

  • expect_rendered : bool

close_button_voice(app: ImguiApp; dwell: uint; header_target: string; child_target: string )

Click the X-button of a CLOSABLE container header (collapsing_header with closable=true, or any closable strip whose header bbox is in telemetry) UNDER an already-posted voice line, then assert the whole strip vanished by checking child_target is no longer rendered — a closable header that closes stops drawing its entire body. The X sits a half header-height in from the header’s right edge (mirrors how ImGui lays the close button out). Self-verifying via record_check_rendered (expect=false): a missed close aborts the recording at teardown. The caller should move_to the header first. Every hold is PACING; the close is polled, not guessed.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • header_target : string

  • child_target : string

combo_pick_voice(app: ImguiApp; dwell: uint; target: string; item: int )

Open combo target and click item item from its popup, under an already-posted voice line, then assert target.value == item. Clicks the combo box (inset from the left so it never lands on the trailing label), POLLS until the popup renders its per-item sub_bboxes (combos are built as BeginCombo + a Selectable loop, so each item has a real rect), travels the cursor onto item item via widget_component_point and clicks it, then verifies the selection. Self-verifying: a missed open or wrong pick aborts the recording at teardown. Every hold is PACING; the open and the commit are polled, not guessed.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • target : string

  • item : int

drag_through_voice(app: ImguiApp; dwell: uint; target: string; to_pos: tuple<float;float>; drag_ms: int = 0; field: string = "value" )

Drag analog of hold_through_voice: scrub from the CURRENT tracked cursor to to_pos UNDER an already-posted voice line, then assert the drag CHANGED target.payload[field]. The caller must have move_to’d onto the handle first — the press starts at the tracked cursor, so there is no re-position jump. The lead scales with the voice line (~1/3 in, bounded 700..3000ms) so the scrub lands WHILE the narrator describes it instead of always firing ~0.6s in — a continuous drag is timing-sensitive in a way a point click is not. The handle scrubs slowly (constant ~0.30 px/ms so short and long drags read the same), then the caption holds for the rest of the voice. The effect is VERIFIED as a CHANGE from the pre-drag value (record_check_changed) — a no-op drag (landed off, force_set removed, widget frozen) aborts the recording at teardown. drag_ms overrides the auto-scaled duration when > 0. field is the payload key to watch — the default "value" covers sliders/drags; docking passes "size" (splitter resize, where target is a NEIGHBOR window) or "dock_id" (tab re-dock, where target is the dragged window itself), since a dock_window has no value but its pos/size/dock node move under the drag.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • target : string

  • to_pos : tuple<float;float>

  • drag_ms : int

  • field : string

hold_through_voice(app: ImguiApp; dwell: uint; clicks: array<string> )

Pace the click(s) in clicks UNDER an already-posted voice line: a short lead lets the box + voice start, then each click lands (ripple + state change visible while the voice plays), then hold the caption for the rest of its voice. dwell is the value returned by say_begin; pass the caption/voice LITERALS to say_begin at the call site (so prepare_recording can scan them) and forward its result here. The caller must have already moved the cursor onto the first target. Each click is VERIFIED (the effect must register, or the recording aborts) and the tracked cursor is advanced to the click’s resolved point so the next move_to starts there (no teleport). Multi-click stages dwell a beat between picks so each state is visible.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • clicks : array<string>

menu_pick_voice(app: ImguiApp; dwell: uint; header_target: string; item_target: string )

Open a menu-bar menu (header_target, e.g. MAIN_BAR/FILE_MENU) and pick an item inside it (item_target) UNDER an already-posted voice line, navigating the way a human does: click the header, then drop STRAIGHT DOWN the menu’s column onto the item. A direct click on the item would travel diagonally from the header across the menu bar and clip a SIBLING menu header — ImGui hover-switches the open menu mid-travel and the click lands in the wrong menu. The vertical drop stays inside the open menu’s column (menu items span the full menu width, so the header’s center-x lands on the item) and never crosses a sibling. Self-verifying via record_check_rendered: the item must render after the header click (menu opened) and stop rendering after the item click (pick landed + menu closed), or the recording aborts at teardown. The caller should move_to the header’s column (directly below the header, below the bar) first so the open-click travels vertically. Every hold is PACING; the open/close are polled, not guessed.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • header_target : string

  • item_target : string

record_check(app: ImguiApp; desc: string; ok: bool ): bool

Recording-safe verifier for a condition the CALLER already evaluated — the escape hatch for an effect the field-based record_check_* can’t reach: a value nested in a payload ARRAY (a per-series legend shown), a cross-widget invariant, any composed predicate. Pass the boolean you computed (typically a wait_for_*(...) != null gate); on false it accumulates desc into the recording’s failure ledger, surfaced as a teardown panic by with_recording_app — same accumulate-don’t-panic discipline as the rest of the family. app is unused (the caller did the polling) but kept for call-site symmetry. Returns ok.

Arguments:
  • app : ImguiApp

  • desc : string

  • ok : bool

record_check_changed(app: ImguiApp; target: string; field: string; before: string; max_frames: int = 120 ): bool

Recording-safe “the gesture moved it” verifier: assert target.payload[field]’s JSON now DIFFERS from before (the value captured before the gesture) within max_frames. This is the honest self-test for a continuous drag — a human drag lands on no exact pixel value, so we assert the value CHANGED rather than equals a brittle pixel-derived target. Accumulate-on-miss (surfaced as a teardown panic), mirroring record_check_value; drag_through_voice uses it. Returns true once the value differs.

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • before : string

  • max_frames : int

record_check_kind_count(app: ImguiApp; kind_prefix: string; min_count: int; max_frames: int = 120 ): bool

Recording-safe “did the rail paint” verifier for entries that register under SYNTHESIZED path keys rather than a caller-declared ident — drawlist primitives ([drawlist_prim], kind "add_*"). Asserts the snapshot registry holds at least min_count entries whose kind starts with kind_prefix within max_frames. record_check_rendered targets one ident; this counts a kind family — the only honest signal when the call sites have no stable ident. Accumulate-on-miss (surfaced as a teardown panic), mirroring record_check_rendered. Returns true on match.

Arguments:
  • app : ImguiApp

  • kind_prefix : string

  • min_count : int

  • max_frames : int

record_check_rendered(app: ImguiApp; target: string; expected: bool; max_frames: int = 120 ): bool

Recording-safe “did the gesture show/hide it” verifier: assert target’s rendered-this-frame state (widget_rendered) equals expected within max_frames. The robust signal for a container whose body is gated on open/close (tree_node / collapsing_header): collapsing a tree stops its body drawing, so a child leaf flips to rendered:false — exactly the “body runs only while open” behavior the widget teaches, and readable even when the container’s own opened/flags are absent from the payload (@optional omits a false opened; an OR-combined flag enum currently serializes to nothing). Accumulate-on-miss (surfaced as a teardown panic), mirroring record_check_value; toggle_tree_voice uses it. Returns true on match.

Arguments:
  • app : ImguiApp

  • target : string

  • expected : bool

  • max_frames : int

record_check_unchanged(app: ImguiApp; target: string; field: string; before: string ): bool

Recording-safe “the gesture left it alone” verifier - the mirror of record_check_changed. Asserts target.payload[field]’s JSON still EQUALS before (the value captured before the gesture). This is the honest self-test for a NEGATIVE claim - e.g. a node OUTSIDE a group does NOT move when the group is dragged. Unlike record_check_changed there is no event to wait for, so the caller must settle the gesture first (wait_for_mouse_idle); this then compares once. Accumulate-on-miss (surfaced as a teardown panic), mirroring record_check_changed. Returns true when the value is still before.

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • before : string

record_check_value(app: ImguiApp; target: string; field: string; expected: auto(T); max_frames: int = 120 ): bool

Recording-safe verifier: assert target.payload[field] == expected within max_frames, accumulating a miss instead of panicking. This is the public rail for self-verifying a NON-CLICK effect in a recording — drive the real gesture (move_to to hover, drag / drag_to, type_text) then call this to assert the effect landed (the hover status updated, the slider value moved, the field took the text). Clicks already verify through hold_through_voice / verify_click_effect, which call this internally; force_set_verified uses it too. A recording body must NOT panic mid-run: daslang panic is fatal and can’t be recovered-and-continued, so a mid-body panic would skip the clean record_stop + /shutdown and orphan the host. So on a mismatch we push the same diagnostic expect_value would panic with onto g_record_failures and return false; the body runs to its normal teardown, then with_recording_app fails loudly after the host is drained. Returns true when the value matched.

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • expected : auto(T)

  • max_frames : int

4.1.6.1. say

say(app: ImguiApp; text: string; target: string = ""; read_ms: uint = NARRATE_READ_MS; voice: string = "" )

Pop a narration overlay (imgui_narrate verb), then hold for the dwell so the recorder captures the on-screen window. The overlay’s duration_s is consumed on the same clock as the hold (the app’s dt — content-time under recording), not a tick budget, so it lingers exactly across the beat even though render fps and capture fps diverge. In voiceover mode the dwell is the wav length (+gap) instead, so the caption stays up exactly while the voice speaks. Named say (not narrate) to avoid overload-resolution churn with imgui_visual_aids::narrate (the host-side overlay function), which differs only in the first arg type (string vs ImguiApp). For a click/drag that must land under the voice, use say_begin + a manual dwell split instead.

Arguments:
  • app : ImguiApp

  • text : string

  • target : string

  • read_ms : uint

  • voice : string

say(app: ImguiApp; text: string; targets: array<string>; read_ms: uint = NARRATE_READ_MS; voice: string = "" )

4.1.6.2. say_begin

say_begin(app: ImguiApp; text: string; target: string = ""; read_ms: uint = NARRATE_READ_MS; voice: string = "" ): uint

Non-blocking say: post the narration overlay (+ record the voiceover anchor) and RETURN the dwell in milliseconds WITHOUT holding. The caller then holds the returned dwell (content-time under recording) — splitting it around a click / drag so the action lands visibly under the voice (cursor lands -> say_begin -> short lead -> click -> hold the remainder). For plain narration with no mid-dwell action, use say which posts + holds in one call. text is the on-screen caption; voice (when set) is the spoken line — write captions for the eye (terse) and voice for the ear (natural sentences). Empty voice falls back to text. For a step spanning sibling controls, use the targets overload.

Arguments:
  • app : ImguiApp

  • text : string

  • target : string

  • read_ms : uint

  • voice : string

say_begin(app: ImguiApp; text: string; targets: array<string>; read_ms: uint = NARRATE_READ_MS; voice: string = "" ): uint

say_hash(text: string ): string

Stable wav key shared by prepare_recording.das and say(); hash() is deterministic.

Arguments:
  • text : string

toggle_tree_voice(app: ImguiApp; dwell: uint; header_target: string; on_arrow: bool; child_target: string; expect_child_rendered: bool )

Click a tree_node (or collapsing_header) header UNDER an already-posted voice line, then assert the toggle landed by checking child_target’s rendered state — the body-runs-only-while-open signal (a collapsed node stops drawing its children). on_arrow=true clicks the chevron (a small inset from the header’s left edge, scaled by header height); on_arrow=false clicks the header center / label. The distinction is the whole point of OpenOnArrow: arrow toggles, label is a no-op — drive both for real and verify each. Requires the header bbox in telemetry (tree_node captures it via current_item_bbox since this rail). Self-verifying via record_check_rendered: a missed toggle (or a label click that wrongly toggled) aborts the recording at teardown. The caller should move_to the header first so the cursor is already near it. Every hold is PACING; the toggle effect is polled, not guessed.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • header_target : string

  • on_arrow : bool

  • child_target : string

  • expect_child_rendered : bool

type_into_voice(app: ImguiApp; dwell: uint; target: string; click_pos: tuple<float;float>; cells: array<string>; expected: auto(T) ): auto

Type analog of drag_through_voice: under an already-posted voice line, drive a REAL keyboard edit of target and assert the committed value == expected. CLICKS click_pos to focus the (first) field — a real click, NOT imgui_focus, because ImGui’s InputScalar AutoSelectAll fires only on activation, so the click both focuses AND selects the existing text (typing then REPLACES rather than inserts). Then per cells entry: type it, Tab to the next field BETWEEN entries (vector inputs cycle their components on Tab), and finally Enter to commit. A scalar is one cell (["12.5"]); a float3 is three. The lead scales with the voice (~1/3 in, bounded) so the typing lands while the narrator describes it; the caption holds the rest. A wrong or uncommitted value aborts the recording at teardown (record_check_value). The caller must have move_to’d click_pos first so the cursor is already on the field (no teleport). Every hold here is PACING; focus, focus-moved and value-committed are each POLLED, not guessed.

Arguments:
  • app : ImguiApp

  • dwell : uint

  • target : string

  • click_pos : tuple<float;float>

  • cells : array<string>

  • expected : auto(T)

4.1.7. Content-time gating

hold_content(app: ImguiApp; ms: uint )

Hold for ms of CONTENT time: under lockstep recording wait for ms/1000*fps captured frames; otherwise a plain wall-clock sleep(ms).

Arguments:
hold_remainder_content(app: ImguiApp; dwell_ms: uint; start_frames: int )

Pad a beat to dwell_ms of CONTENT time: wait until the recorder reaches start_frames + dwell_ms/1000*fps (start_frames from record_frame_count right after say_begin). Work done mid-dwell consumes content frames; this holds the rest. The content-clock replacement for a driver’s wall-clock hold_remainder. No-op when idle.

Arguments:
  • app : ImguiApp

  • dwell_ms : uint

  • start_frames : int

record_frame_count(app: ImguiApp ): int

Current captured-frame count (content-time index). Capture right after say_begin to anchor a hold_remainder_content. Returns 0 outside a recording session.

Arguments:
wait_content_frames(app: ImguiApp; frames: int )

Block until the recorder captures frames more frames (= frames/fps seconds of video). No-op outside a recording session. The target is anchored on the first VALID status read (a transient null never lowers it), and a null read keeps polling rather than ending the hold early. If capture stalls (frames don’t advance for RECORD_STALL_US — the app isn’t rendering during this hold), degrade to a wall-clock sleep for the remaining span so the beat still lands and CI never hangs.

Arguments:

4.1.8. Snapshots

expect_render(app: ImguiApp; widget_ident: string; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC )

Assertion form of wait_for_render; panics on timeout with the list of currently-registered widget identifiers (so typos in widget_ident surface).

Arguments:
  • app : ImguiApp

  • widget_ident : string

  • timeout_sec : float

expect_value(app: ImguiApp; target: string; field: string; expected: auto(T); max_frames: int = 300 )

Assertion form of wait_for_payload_value; panics with a focused want/got/kind/rendered message on timeout.

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • expected : auto(T)

  • max_frames : int

log_snapshot(app: ImguiApp; note: string = "" )

Dump the live UI state to the log (LOG_WARNING) — for every registered widget: its ident, kind, rendered flag, bbox, and payload.value when present. The way to see what a headless/CI run actually painted when you can’t watch the window; pair it with a failed assertion (dump on the failure path) so a red run is self-explaining.

Arguments:
snapshot(app: ImguiApp ): JsonValue?

Fetch the full widget snapshot (imgui_snapshot command) as a parsed JsonValue.

Arguments:

4.1.9. Polling / await

auto_dump_on_timeout_enabled(): bool

def auto_dump_on_timeout_enabled () : bool

await_probe(app: ImguiApp; until: string = "quiescent" ): JsonValue?

Single imgui_await POST; the AwaitResult lives under response.value. For poll-until-quiescent use await_quiescent.

Arguments:
await_quiescent(app: ImguiApp; timeout_sec: float = 5f )

Assertion wrapper over await_imgui(transport, "quiescent", timeout); panics on timeout. For a non-asserting poll, call await_imgui directly.

Arguments:
set_auto_dump_on_timeout(enabled: bool )

Toggle the wait-family auto-dump (default on). When on, any wait_* helper that gives up logs the live UI state once via log_snapshot before returning null / false, so a red headless or slow-CI run is self-explaining with zero per-test dump code. Turn it off around a negative wait — one where the timeout is the asserted, expected outcome (e.g. confirming a widget never appears) — so the log isn’t spammed with an expected miss.

Arguments:
  • enabled : bool

wait_cond(app: ImguiApp; timeout_sec: float; pred: block<():void> ): bool

Bare wall-clock condition poll — no snapshot, no widget coupling. Polls pred() until it returns true, up to timeout_sec real seconds; returns true on match, false on timeout. The cmd_* sibling of wait_until_sec: for state gated on a command round-trip (sim tick, camera mode, scene count) rather than a widget snapshot field. Replaces the hand-rolled for (_ in range(N)) { if (cond) break } busy-poll, which bounds by frame count with no sleep and so expires prematurely against the multi-kHz headless server (see wait_until).

Arguments:
  • app : ImguiApp

  • timeout_sec : float

  • pred : block<void>

wait_for_bool_value(app: ImguiApp; target: string; field: string; expected: bool; max_frames: int = 300 ): bool

Poll snapshot until globals[target].payload[field] == expected (typed bool).

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • expected : bool

  • max_frames : int

wait_for_hover(app: ImguiApp; widget_ident: string; expected: bool = true; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC ): JsonValue?

Poll snapshot until registered widget widget_ident’s hover state (IsItemHovered, the per-widget hover field) equals expected. The sync gate for a reliable synthetic click: move_to(point) then wait_for_hover(target) then click guarantees the press lands a frame AFTER hover is established. Without it, a synthetic move``+``press can batch into one windowed-vsync frame and the press fires before HoveredId is set — the press-before-hover race a real continuous-hover mouse never hits (synthetic != real). Bounded by timeout_sec; returns the matching snapshot or null on timeout. null is a real failure — gate it with record_check (recording) or success (test); never proceed to the click silently. The widget_exists guard is load-bearing for expected=false: an absent / misspelled ident yields a null hover that ?? false would coalesce to false, so a missing widget would false-pass as “not hovered” and mask a sync failure — same guard as widget_rendered.

Arguments:
  • app : ImguiApp

  • widget_ident : string

  • expected : bool

  • timeout_sec : float

wait_for_int_value(app: ImguiApp; target: string; field: string; expected: int; max_frames: int = 300 ): bool

Poll snapshot until globals[target].payload[field] == expected (typed int). Returns true on match, false after max_frames frames elapse.

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • expected : int

  • max_frames : int

wait_for_key_idle(app: ImguiApp; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC ): bool

Poll imgui_key_status until playing == false or timeout; returns true on idle. Uses one HTTP request per poll (unlike a wait_until body that pairs a snapshot with a status call), keeping the per-subprocess connection count low on Windows.

Arguments:
wait_for_mouse_idle(app: ImguiApp; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC ): bool

Poll imgui_mouse_status until playing == false or timeout; returns true on idle. Uses one HTTP request per poll (unlike a wait_until body that pairs a snapshot with a status call), keeping the per-subprocess connection count low on Windows.

Arguments:
wait_for_payload_value(app: ImguiApp; target: string; field: string; expected: auto(T); max_frames: int = 300 ): bool

Generic poll: snapshot until globals[target].payload[field] == expected; T is inferred from expected. Use for any roundtrippable type (float2/3/4, int2/3/4, etc.) where the typed sugars above don’t apply.

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • expected : auto(T)

  • max_frames : int

wait_for_render(app: ImguiApp; widget_ident: string; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC ): JsonValue?

First-paint gate — alias of wait_for_visible(). widget_rendered now requires existence, so this no longer false-passes for an absent path. Prefer wait_for_visible at call sites that target a widget which can hide and re-show.

Arguments:
  • app : ImguiApp

  • widget_ident : string

  • timeout_sec : float

wait_for_string_value(app: ImguiApp; target: string; field: string; expected: string; max_frames: int = 300 ): bool

Poll snapshot until globals[target].payload[field] == expected (typed string).

Arguments:
  • app : ImguiApp

  • target : string

  • field : string

  • expected : string

  • max_frames : int

wait_for_visible(app: ImguiApp; widget_ident: string; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC ): JsonValue?

Poll snapshot until widget_ident is registered AND painted this frame (widget_rendered). The robust gate for a widget that can hide and re-show: wait_for_widget returns on a stale registry entry left from a prior appearance (e.g. a re-opened popup / menu) while the widget is currently off-screen, so a click fired right after can dispatch before the widget re-renders and be silently dropped (no bbox this frame). Use this whenever the click target may have been shown before.

Arguments:
  • app : ImguiApp

  • widget_ident : string

  • timeout_sec : float

wait_for_widget(app: ImguiApp; widget_ident: string; timeout_sec: float = DEFAULT_FRAME_WAIT_SEC ): JsonValue?

Poll snapshot until globals[widget_ident] is present (registered at module init OR rendered at least once). Use for subprocess-startup gating; for first-paint semantics use wait_for_render.

Arguments:
  • app : ImguiApp

  • widget_ident : string

  • timeout_sec : float

wait_until(app: ImguiApp; max_frames: int; pred: block<(var snap:JsonValue?):bool> ): JsonValue?

Poll pred(snap) up to max_frames 60 fps-equivalent server frames (max_frames/60 seconds). Returns the matching snapshot or null on timeout.

The legacy semantics treated max_frames as “server frame counter delta”, which fired prematurely under headless: the server advances g_frame at multi-kHz, so max_frames=240 could expire in ~24 ms — far short of any time-driven server work (timeline completion, fixed-duration animations, ImGui state convergence). The translation to wall-clock at the conventional 60 fps preserves the headed-mode budget every test was tuned against, without the per-mode skew. For new code that’s natively expressed in seconds, prefer wait_until_sec.

Arguments:
wait_until_ready(app: ImguiApp; timeout_sec: float ): bool

Poll GET /status until 200 OK or timeout. Returns true on ready.

Arguments:
wait_until_sec(app: ImguiApp; timeout_sec: float; pred: block<(var snap:JsonValue?):bool> ): JsonValue?

Wall-clock variant of wait_until: poll pred(snap) until it returns true, up to timeout_sec real seconds. Returns the matching snapshot or null on timeout. Prefer this over wait_until for tests gated on time-driven server work (timeline completion, fixed-duration animations) — the frame-budget form fires prematurely in headless mode where server frames run at multi-kHz with no vsync.

Arguments:

4.1.10. Pause control

pause(app: ImguiApp )

POST /pause to halt the host’s update() (skips advance_coroutines + render). POST /command continues to dispatch and POSTs still get drained by server.tick() (which runs from the debug agent’s onTick, gated by auto_tick_agents — not by paused). Useful for deterministic observation: spawn an L1 coroutine, probe imgui_await while the world is frozen, then unpause to let it run.

Arguments:
unpause(app: ImguiApp )

POST /unpause to resume the host’s update().

Arguments:
with_paused(app: ImguiApp; body: block<():void> )

Scope: pause the host, invoke body, unpause. Body observes a frozen world — coroutines spawned by imgui_click sit unadvanced until unpause, so imgui_await reports pending_coroutines >= 1 deterministically even in headless mode where the per-frame advance otherwise races the next HTTP POST.

Arguments:

4.1.11. Transport plumbing

post_command(app: ImguiApp; name: string; args: JsonValue? ): JsonValue?

Send {"name":<name>,"args":<args>} through app.transport; returns parsed JSON or null on transport failure. Escape hatch for custom verbs outside the shipped helper set.

Arguments: