Skip to main content

fret_diag_protocol/
lib.rs

1//! Stable, serializable protocol types for Fret diagnostics and scripted UI automation.
2//!
3//! The diagnostics pipeline intentionally uses explicit schema versions (e.g. `*V1`, `*V2`) so
4//! tooling can evolve without breaking older bundles/scripts.
5//!
6//! Most users interact with this crate indirectly via `fretboard diag` and the JSON artifacts in
7//! `tools/diag-scripts/`.
8
9use serde::{Deserialize, Serialize};
10
11pub mod builder;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14/// Envelope message for diagnostics/devtools transports.
15///
16/// Transports (e.g. WebSockets) send a `type` discriminator and a free-form JSON `payload`.
17/// Higher-level tooling is responsible for validating the schema version and payload structure.
18pub struct DiagTransportMessageV1 {
19    pub schema_version: u32,
20    pub r#type: String,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub session_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub request_id: Option<u64>,
25    #[serde(default)]
26    pub payload: serde_json::Value,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30/// Hello message sent by a client when attaching to a devtools server.
31pub struct DevtoolsHelloV1 {
32    pub client_kind: String,
33    pub client_version: String,
34    #[serde(default)]
35    pub capabilities: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39/// Acknowledgement message returned by the devtools server after receiving [`DevtoolsHelloV1`].
40pub struct DevtoolsHelloAckV1 {
41    pub server_version: String,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub server_capabilities: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DevtoolsSessionDescriptorV1 {
48    pub session_id: String,
49    pub client_kind: String,
50    pub client_version: String,
51    #[serde(default)]
52    pub capabilities: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct DevtoolsSessionListV1 {
57    pub sessions: Vec<DevtoolsSessionDescriptorV1>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DevtoolsSessionAddedV1 {
62    pub session: DevtoolsSessionDescriptorV1,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DevtoolsSessionRemovedV1 {
67    pub session_id: String,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct UiScriptMetaV1 {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub name: Option<String>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub tags: Vec<String>,
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub required_capabilities: Vec<String>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub target_hints: Vec<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(tag = "kind", rename_all = "snake_case")]
84pub enum UiImeEventV1 {
85    Enabled,
86    Disabled,
87    Commit {
88        text: String,
89    },
90    /// IME preedit update.
91    ///
92    /// `cursor_bytes` is a byte-indexed range in the preedit string (begin, end).
93    /// When `None`, the cursor should be hidden.
94    Preedit {
95        text: String,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        cursor_bytes: Option<(u32, u32)>,
98    },
99    /// Delete text surrounding the cursor or selection.
100    ///
101    /// Offsets are expressed in UTF-8 bytes.
102    DeleteSurrounding {
103        before_bytes: u32,
104        after_bytes: u32,
105    },
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109/// Scripted UI interaction plan (schema v1).
110///
111/// Used by `fretboard diag` to drive automated UI actions and assertions.
112pub struct UiActionScriptV1 {
113    pub schema_version: u32,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub meta: Option<UiScriptMetaV1>,
116    pub steps: Vec<UiActionStepV1>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121pub enum UiActionStepV1 {
122    Click {
123        target: UiSelectorV1,
124        #[serde(default)]
125        button: UiMouseButtonV1,
126        #[serde(
127            default = "default_click_count",
128            skip_serializing_if = "is_default_click_count"
129        )]
130        click_count: u8,
131    },
132    ResetDiagnostics,
133    MovePointer {
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        window: Option<UiWindowTargetV1>,
136        target: UiSelectorV1,
137    },
138    DragPointer {
139        target: UiSelectorV1,
140        #[serde(default)]
141        button: UiMouseButtonV1,
142        delta_x: f32,
143        delta_y: f32,
144        #[serde(default = "default_drag_steps")]
145        steps: u32,
146    },
147    Wheel {
148        target: UiSelectorV1,
149        #[serde(default)]
150        delta_x: f32,
151        #[serde(default)]
152        delta_y: f32,
153    },
154    PressKey {
155        key: String,
156        #[serde(default)]
157        modifiers: UiKeyModifiersV1,
158        #[serde(default)]
159        repeat: bool,
160    },
161    TypeText {
162        text: String,
163    },
164    WaitFrames {
165        n: u32,
166    },
167    WaitUntil {
168        predicate: UiPredicateV1,
169        timeout_frames: u32,
170    },
171    Assert {
172        predicate: UiPredicateV1,
173    },
174    CaptureBundle {
175        label: Option<String>,
176        #[serde(default, skip_serializing_if = "Option::is_none")]
177        max_snapshots: Option<u32>,
178    },
179    CaptureScreenshot {
180        label: Option<String>,
181        #[serde(default = "default_capture_screenshot_timeout_frames")]
182        timeout_frames: u32,
183    },
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187/// Scripted UI interaction plan (schema v2).
188///
189/// This is the preferred schema for new scripts and generators.
190pub struct UiActionScriptV2 {
191    pub schema_version: u32,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub meta: Option<UiScriptMetaV1>,
194    pub steps: Vec<UiActionStepV2>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct FilesystemCapabilitiesHintsV1 {
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub allow_script_schema_v1: Option<bool>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub write_bundle_schema2: Option<bool>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct FilesystemCapabilitiesV1 {
207    pub schema_version: u32,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub capabilities: Vec<String>,
210    /// Optional runner identity for auditability (additive; tooling must treat as hints).
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub runner_kind: Option<String>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub runner_version: Option<String>,
215    /// Optional schema/config hints for tooling and triage (additive).
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub hints: Option<FilesystemCapabilitiesHintsV1>,
218}
219
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct UiDiagnosticsConfigPathsV1 {
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub trigger_path: Option<String>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub ready_path: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub exit_path: Option<String>,
228
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub screenshot_request_path: Option<String>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub screenshot_trigger_path: Option<String>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub screenshot_result_path: Option<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub screenshot_result_trigger_path: Option<String>,
237
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub script_path: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub script_trigger_path: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub script_result_path: Option<String>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub script_result_trigger_path: Option<String>,
246
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub pick_trigger_path: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub pick_result_path: Option<String>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub pick_result_trigger_path: Option<String>,
253
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub inspect_path: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub inspect_trigger_path: Option<String>,
258}
259
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
261pub struct UiDiagnosticsConfigFileV1 {
262    pub schema_version: u32,
263
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub enabled: Option<bool>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub out_dir: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub paths: Option<UiDiagnosticsConfigPathsV1>,
270
271    /// Whether the diagnostics runtime should accept script schema v1 inputs.
272    ///
273    /// When `None`, the runtime uses its default policy (currently: allow in manual flows; tooling
274    /// typically writes an explicit `false` for launched runs).
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub allow_script_schema_v1: Option<bool>,
277
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub script_keepalive: Option<bool>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub script_auto_dump: Option<bool>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub pick_auto_dump: Option<bool>,
284
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub max_events: Option<u32>,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub max_snapshots: Option<u32>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub script_dump_max_snapshots: Option<u32>,
291
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub capture_semantics: Option<bool>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub max_semantics_nodes: Option<u32>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub semantics_test_ids_only: Option<bool>,
298
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub screenshots_enabled: Option<bool>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub screenshot_on_dump: Option<bool>,
303
304    /// Whether the diagnostics runtime should write the large raw bundle artifact (`bundle.json`)
305    /// during dumps.
306    ///
307    /// Tooling typically sets this to `false` for launched runs so default artifacts stay
308    /// small-by-default (manifest + sidecars + optional compact bundle view).
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub write_bundle_json: Option<bool>,
311    /// Whether the diagnostics runtime should write the compact bundle view (`bundle.schema2.json`)
312    /// alongside sidecars during dumps.
313    ///
314    /// This is intended for schema2-first + AI/sidecar-first workflows to avoid requiring tooling
315    /// to parse large raw bundles just to produce a portable artifact.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub write_bundle_schema2: Option<bool>,
318
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub redact_text: Option<bool>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub max_debug_string_bytes: Option<u32>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub max_gating_trace_entries: Option<u32>,
325
326    /// When enabled, ignore external pointer input events (mouse/touch/pen) while a diagnostics
327    /// script is running.
328    ///
329    /// This is intended to keep scripted runs deterministic when a user accidentally moves or
330    /// clicks the real mouse during playback (especially for cross-window docking/tear-off).
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub isolate_external_pointer_input_while_script_running: Option<bool>,
333
334    /// When enabled, ignore external keyboard/text/IME events while a diagnostics script is
335    /// running.
336    ///
337    /// This is intended to keep scripted runs deterministic when a user accidentally types while
338    /// playback is in progress (especially when scripts are asserting shortcut routing or text
339    /// input outcomes).
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub isolate_external_keyboard_input_while_script_running: Option<bool>,
342
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub frame_clock_fixed_delta_ms: Option<u64>,
345
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub devtools_embed_bundle: Option<bool>,
348}
349
350#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
351pub struct UiPaddingInsetsV1 {
352    pub left_px: f32,
353    pub top_px: f32,
354    pub right_px: f32,
355    pub bottom_px: f32,
356}
357
358impl UiPaddingInsetsV1 {
359    pub fn uniform(padding_px: f32) -> Self {
360        let p = padding_px.max(0.0);
361        Self {
362            left_px: p,
363            top_px: p,
364            right_px: p,
365            bottom_px: p,
366        }
367    }
368}
369
370#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
371#[serde(tag = "kind", rename_all = "snake_case")]
372pub enum UiWindowTargetV1 {
373    /// Target the window currently driving the script step.
374    Current,
375    /// Target the first window observed by the diagnostics runtime.
376    FirstSeen,
377    /// Target the first observed window that is not the current window.
378    FirstSeenOther,
379    /// Target the most recently observed window.
380    LastSeen,
381    /// Target the most recently observed window that is not the current window.
382    LastSeenOther,
383    /// Target a specific window by its FFI handle/id as reported in bundles and script results.
384    WindowFfi { window: u64 },
385}
386
387#[derive(Default, Debug, Clone, Serialize, Deserialize)]
388#[serde(tag = "kind", rename_all = "snake_case")]
389pub enum UiInsetsOverrideV1 {
390    #[default]
391    NoChange,
392    Clear,
393    Set {
394        insets_px: UiPaddingInsetsV1,
395    },
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399#[serde(tag = "kind", rename_all = "snake_case")]
400pub enum UiIncomingOpenInjectItemV1 {
401    /// Diagnostics-only UTF-8 file payload.
402    ///
403    /// This is intended for CI fixtures and does not model binary files or platform handles.
404    FileUtf8 {
405        name: String,
406        text: String,
407        #[serde(default, skip_serializing_if = "Option::is_none")]
408        media_type: Option<String>,
409    },
410    Text {
411        text: String,
412        #[serde(default, skip_serializing_if = "Option::is_none")]
413        media_type: Option<String>,
414    },
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case")]
419pub enum UiClipboardWriteResultV1 {
420    Success,
421    Failure,
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "snake_case")]
426pub enum UiClipboardAccessErrorKindV1 {
427    Unavailable,
428    PermissionDenied,
429    UserActivationRequired,
430    Unsupported,
431    BackendError,
432    Unknown,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436#[serde(tag = "type", rename_all = "snake_case")]
437pub enum UiActionStepV2 {
438    // v1-compatible steps
439    Click {
440        #[serde(default, skip_serializing_if = "Option::is_none")]
441        window: Option<UiWindowTargetV1>,
442        #[serde(default, skip_serializing_if = "Option::is_none")]
443        pointer_kind: Option<UiPointerKindV1>,
444        target: UiSelectorV1,
445        #[serde(default)]
446        button: UiMouseButtonV1,
447        #[serde(
448            default = "default_click_count",
449            skip_serializing_if = "is_default_click_count"
450        )]
451        click_count: u8,
452        #[serde(default, skip_serializing_if = "Option::is_none")]
453        modifiers: Option<UiKeyModifiersV1>,
454    },
455    /// A high-level “tap” gesture (touch-first) resolved via semantics selectors.
456    ///
457    /// This is intended for mobile-style interaction policies where "click" is an imprecise term.
458    /// Runtime injection still maps to unified pointer events with `PointerType::Touch` by default.
459    Tap {
460        #[serde(default, skip_serializing_if = "Option::is_none")]
461        window: Option<UiWindowTargetV1>,
462        /// Optional override; when omitted, defaults to `touch`.
463        #[serde(default, skip_serializing_if = "Option::is_none")]
464        pointer_kind: Option<UiPointerKindV1>,
465        target: UiSelectorV1,
466        #[serde(default, skip_serializing_if = "Option::is_none")]
467        modifiers: Option<UiKeyModifiersV1>,
468    },
469    /// A high-level “long press” gesture (touch-first) resolved via semantics selectors.
470    ///
471    /// Runtime injection emits a `pointer_down`, holds until `duration_ms` elapses, then emits
472    /// `pointer_up` with `is_click=false`.
473    LongPress {
474        #[serde(default, skip_serializing_if = "Option::is_none")]
475        window: Option<UiWindowTargetV1>,
476        /// Optional override; when omitted, defaults to `touch`.
477        #[serde(default, skip_serializing_if = "Option::is_none")]
478        pointer_kind: Option<UiPointerKindV1>,
479        target: UiSelectorV1,
480        #[serde(
481            default = "default_long_press_duration_ms",
482            skip_serializing_if = "is_default_long_press_duration_ms"
483        )]
484        duration_ms: u64,
485        #[serde(default, skip_serializing_if = "Option::is_none")]
486        modifiers: Option<UiKeyModifiersV1>,
487    },
488    /// A high-level “swipe” gesture (touch-first) resolved via semantics selectors.
489    ///
490    /// Runtime injection emits a `pointer_down` at the target's center, then a sequence of
491    /// `pointer_move` events to the end position, then a `pointer_up` with `is_click=false`.
492    Swipe {
493        #[serde(default, skip_serializing_if = "Option::is_none")]
494        window: Option<UiWindowTargetV1>,
495        /// Optional override; when omitted, defaults to `touch`.
496        #[serde(default, skip_serializing_if = "Option::is_none")]
497        pointer_kind: Option<UiPointerKindV1>,
498        target: UiSelectorV1,
499        delta_x: f32,
500        delta_y: f32,
501        #[serde(
502            default = "default_drag_steps",
503            skip_serializing_if = "is_default_drag_steps"
504        )]
505        steps: u32,
506        #[serde(default, skip_serializing_if = "Option::is_none")]
507        modifiers: Option<UiKeyModifiersV1>,
508    },
509    /// A pinch/zoom gesture emitted at the target's center.
510    ///
511    /// `delta` is positive for zoom in and negative for zoom out (matches `PointerEvent::PinchGesture`).
512    Pinch {
513        #[serde(default, skip_serializing_if = "Option::is_none")]
514        window: Option<UiWindowTargetV1>,
515        /// Optional override; when omitted, defaults to `touch`.
516        #[serde(default, skip_serializing_if = "Option::is_none")]
517        pointer_kind: Option<UiPointerKindV1>,
518        target: UiSelectorV1,
519        /// Total delta across all steps.
520        delta: f32,
521        #[serde(
522            default = "default_drag_steps",
523            skip_serializing_if = "is_default_drag_steps"
524        )]
525        steps: u32,
526        #[serde(default, skip_serializing_if = "Option::is_none")]
527        modifiers: Option<UiKeyModifiersV1>,
528    },
529    ResetDiagnostics,
530    /// Set a “base reference” for subsequent selector-driven steps.
531    ///
532    /// When a base reference is set, the runtime may scope later selector resolution to the
533    /// base node's subtree (window-local) until [`UiActionStepV2::ClearBaseRef`] is executed.
534    ///
535    /// This is an ergonomics feature (ImGui `SetRef(...)`-style outcome) intended to reduce
536    /// repetition and diff noise in long scripts.
537    SetBaseRef {
538        #[serde(default, skip_serializing_if = "Option::is_none")]
539        window: Option<UiWindowTargetV1>,
540        target: UiSelectorV1,
541    },
542    /// Clear the active base reference, restoring global selector resolution.
543    ClearBaseRef,
544    /// Semantically activate a target using the runtime accessibility action surface.
545    ///
546    /// This bypasses pointer hit-testing and is primarily intended for diagnosis: it helps
547    /// distinguish "semantics can activate" from "pointer cannot hit the target".
548    Activate {
549        #[serde(default, skip_serializing_if = "Option::is_none")]
550        window: Option<UiWindowTargetV1>,
551        target: UiSelectorV1,
552    },
553    /// Move accessibility focus to a target without invoking it.
554    ///
555    /// This is intended for diagnosis and parity checks where you want to distinguish
556    /// focusability from pointer hit-testing and activation behavior.
557    Focus {
558        #[serde(default, skip_serializing_if = "Option::is_none")]
559        window: Option<UiWindowTargetV1>,
560        target: UiSelectorV1,
561    },
562    MovePointer {
563        #[serde(default, skip_serializing_if = "Option::is_none")]
564        window: Option<UiWindowTargetV1>,
565        #[serde(default, skip_serializing_if = "Option::is_none")]
566        pointer_kind: Option<UiPointerKindV1>,
567        target: UiSelectorV1,
568    },
569    /// Move the pointer to a target and issue a pointer down, keeping the session active across
570    /// subsequent steps (until `pointer_up`).
571    ///
572    /// This is intended for scripted "drag + key" flows (e.g. press Escape while dragging).
573    PointerDown {
574        #[serde(default, skip_serializing_if = "Option::is_none")]
575        window: Option<UiWindowTargetV1>,
576        #[serde(default, skip_serializing_if = "Option::is_none")]
577        pointer_kind: Option<UiPointerKindV1>,
578        target: UiSelectorV1,
579        #[serde(default)]
580        button: UiMouseButtonV1,
581        #[serde(default, skip_serializing_if = "Option::is_none")]
582        modifiers: Option<UiKeyModifiersV1>,
583    },
584    DragPointer {
585        #[serde(default, skip_serializing_if = "Option::is_none")]
586        window: Option<UiWindowTargetV1>,
587        #[serde(default, skip_serializing_if = "Option::is_none")]
588        pointer_kind: Option<UiPointerKindV1>,
589        target: UiSelectorV1,
590        #[serde(default)]
591        button: UiMouseButtonV1,
592        #[serde(default = "default_true")]
593        clamp_to_window_bounds: bool,
594        delta_x: f32,
595        delta_y: f32,
596        #[serde(default = "default_drag_steps")]
597        steps: u32,
598    },
599    /// Move the pointer while a `pointer_down` session is active.
600    ///
601    /// This emits `PointerEvent::Move` with pressed buttons and also mirrors internal drag routing
602    /// by emitting `InternalDrag::Over` events (safe unless a cross-window internal-drag session is
603    /// active).
604    PointerMove {
605        #[serde(default, skip_serializing_if = "Option::is_none")]
606        window: Option<UiWindowTargetV1>,
607        #[serde(default, skip_serializing_if = "Option::is_none")]
608        pointer_kind: Option<UiPointerKindV1>,
609        delta_x: f32,
610        delta_y: f32,
611        #[serde(default = "default_drag_steps")]
612        steps: u32,
613    },
614    /// Release an active `pointer_down` session.
615    ///
616    /// This emits `PointerEvent::Up` and mirrors internal drag routing by emitting
617    /// `InternalDrag::Drop`.
618    PointerUp {
619        #[serde(default, skip_serializing_if = "Option::is_none")]
620        window: Option<UiWindowTargetV1>,
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        pointer_kind: Option<UiPointerKindV1>,
623        #[serde(default, skip_serializing_if = "Option::is_none")]
624        button: Option<UiMouseButtonV1>,
625    },
626    /// Cancel an active `pointer_down` session.
627    ///
628    /// This emits `Event::PointerCancel` and mirrors internal drag routing by emitting
629    /// `InternalDrag::Cancel`.
630    PointerCancel {
631        #[serde(default, skip_serializing_if = "Option::is_none")]
632        window: Option<UiWindowTargetV1>,
633        #[serde(default, skip_serializing_if = "Option::is_none")]
634        pointer_kind: Option<UiPointerKindV1>,
635    },
636    MovePointerSweep {
637        #[serde(default, skip_serializing_if = "Option::is_none")]
638        window: Option<UiWindowTargetV1>,
639        #[serde(default, skip_serializing_if = "Option::is_none")]
640        pointer_kind: Option<UiPointerKindV1>,
641        target: UiSelectorV1,
642        delta_x: f32,
643        delta_y: f32,
644        #[serde(default = "default_drag_steps")]
645        steps: u32,
646        #[serde(default = "default_move_frames_per_step")]
647        frames_per_step: u32,
648    },
649    Wheel {
650        #[serde(default, skip_serializing_if = "Option::is_none")]
651        window: Option<UiWindowTargetV1>,
652        #[serde(default, skip_serializing_if = "Option::is_none")]
653        pointer_kind: Option<UiPointerKindV1>,
654        target: UiSelectorV1,
655        #[serde(default)]
656        delta_x: f32,
657        #[serde(default)]
658        delta_y: f32,
659    },
660    /// Inject a burst of wheel events in a single frame via the native runner (best-effort).
661    ///
662    /// Unlike [`UiActionStepV2::Wheel`], which injects a single `pointer.wheel` event directly into
663    /// the UI event stream, this step is intended to exercise runner-level wheel coalescing by
664    /// synthesizing multiple raw wheel inputs in the same frame.
665    ///
666    /// Requires capability `diag.wheel_burst_inject`.
667    WheelBurst {
668        #[serde(default, skip_serializing_if = "Option::is_none")]
669        window: Option<UiWindowTargetV1>,
670        #[serde(default, skip_serializing_if = "Option::is_none")]
671        pointer_kind: Option<UiPointerKindV1>,
672        target: UiSelectorV1,
673        #[serde(default)]
674        delta_x: f32,
675        #[serde(default)]
676        delta_y: f32,
677        #[serde(
678            default = "default_wheel_burst_count",
679            skip_serializing_if = "is_default_wheel_burst_count"
680        )]
681        count: u32,
682    },
683    PressKey {
684        key: String,
685        #[serde(default)]
686        modifiers: UiKeyModifiersV1,
687        #[serde(default)]
688        repeat: bool,
689    },
690    PressShortcut {
691        shortcut: String,
692        #[serde(default)]
693        repeat: bool,
694    },
695    TypeText {
696        text: String,
697    },
698    /// Inject an IME event into the focused text surface.
699    ///
700    /// This is intended for deterministic regression scripts that need to exercise text/IME
701    /// composition without depending on platform IME integrations.
702    Ime {
703        event: UiImeEventV1,
704    },
705    WaitFrames {
706        #[serde(default, skip_serializing_if = "Option::is_none")]
707        window: Option<UiWindowTargetV1>,
708        n: u32,
709    },
710    /// Wait for a fixed duration (in milliseconds).
711    ///
712    /// This is intended as a last-resort stabilization step when no semantic predicate exists.
713    /// Prefer `wait_until` for deterministic gates.
714    WaitMs {
715        #[serde(default, skip_serializing_if = "Option::is_none")]
716        window: Option<UiWindowTargetV1>,
717        n_ms: u32,
718    },
719    WaitUntil {
720        #[serde(default, skip_serializing_if = "Option::is_none")]
721        window: Option<UiWindowTargetV1>,
722        predicate: UiPredicateV1,
723        #[serde(default = "default_action_timeout_frames")]
724        timeout_frames: u32,
725        #[serde(default, skip_serializing_if = "Option::is_none")]
726        timeout_ms: Option<u32>,
727    },
728    /// Wait until the shortcut routing diagnostics trace contains an entry matching `query`.
729    ///
730    /// This is intended for deterministic scripts that need to assert keyboard routing outcomes
731    /// (e.g. reserved-for-IME) without depending on screenshots or ad-hoc logs.
732    WaitShortcutRoutingTrace {
733        query: UiShortcutRoutingTraceQueryV1,
734        #[serde(default = "default_action_timeout_frames")]
735        timeout_frames: u32,
736        #[serde(default, skip_serializing_if = "Option::is_none")]
737        timeout_ms: Option<u32>,
738    },
739    /// Wait until the command dispatch trace contains an entry matching `query`.
740    ///
741    /// This is intended to gate action-first convergence work: pointer triggers, keymap shortcuts,
742    /// and command palette/menus should all produce explainable dispatch outcomes.
743    WaitCommandDispatchTrace {
744        query: UiCommandDispatchTraceQueryV1,
745        #[serde(default = "default_action_timeout_frames")]
746        timeout_frames: u32,
747        #[serde(default, skip_serializing_if = "Option::is_none")]
748        timeout_ms: Option<u32>,
749    },
750    /// Wait until the overlay placement trace contains an entry matching `query`.
751    ///
752    /// This is intended for overlay-driven components (Select/Combobox/Menus) where correctness
753    /// depends on collision/flip/shift behavior and we want failures to be explainable without
754    /// relying on screenshots.
755    WaitOverlayPlacementTrace {
756        query: UiOverlayPlacementTraceQueryV1,
757        #[serde(default = "default_action_timeout_frames")]
758        timeout_frames: u32,
759        #[serde(default, skip_serializing_if = "Option::is_none")]
760        timeout_ms: Option<u32>,
761    },
762    Assert {
763        #[serde(default, skip_serializing_if = "Option::is_none")]
764        window: Option<UiWindowTargetV1>,
765        predicate: UiPredicateV1,
766    },
767    CaptureBundle {
768        label: Option<String>,
769        #[serde(default, skip_serializing_if = "Option::is_none")]
770        max_snapshots: Option<u32>,
771    },
772    CaptureScreenshot {
773        label: Option<String>,
774        #[serde(default = "default_capture_screenshot_timeout_frames")]
775        timeout_frames: u32,
776        #[serde(default, skip_serializing_if = "Option::is_none")]
777        timeout_ms: Option<u32>,
778    },
779    /// Capture a layout sidecar (native-only, best-effort).
780    ///
781    /// This is intended to make layout regressions explainable via a bundle-scoped sidecar file
782    /// (e.g. `layout.taffy.v1.json`) rather than ad-hoc debug UI in demos.
783    ///
784    /// Tooling should treat missing sidecars as warnings, not failures.
785    CaptureLayoutSidecar {
786        /// Optional label used to name the bundle directory for this capture step.
787        #[serde(default, skip_serializing_if = "Option::is_none")]
788        label: Option<String>,
789        /// Optional debug label filter for selecting a subtree root before dumping.
790        #[serde(default, skip_serializing_if = "Option::is_none")]
791        root_label_filter: Option<String>,
792    },
793
794    // v2 intent-level steps
795    /// Click a target only after its bounds have remained stable for `stable_frames`.
796    ///
797    /// This is useful for virtualized lists where a target's measured bounds can jump
798    /// across frames (e.g. estimate -> measured), causing clicks to land at stale
799    /// positions when using a single-frame snapshot.
800    ClickStable {
801        #[serde(default, skip_serializing_if = "Option::is_none")]
802        window: Option<UiWindowTargetV1>,
803        #[serde(default, skip_serializing_if = "Option::is_none")]
804        pointer_kind: Option<UiPointerKindV1>,
805        target: UiSelectorV1,
806        #[serde(default)]
807        button: UiMouseButtonV1,
808        #[serde(
809            default = "default_click_count",
810            skip_serializing_if = "is_default_click_count"
811        )]
812        click_count: u8,
813        #[serde(default, skip_serializing_if = "Option::is_none")]
814        modifiers: Option<UiKeyModifiersV1>,
815        #[serde(default = "default_click_stable_frames")]
816        stable_frames: u32,
817        #[serde(default = "default_click_stable_max_move_px")]
818        max_move_px: f32,
819        #[serde(default = "default_action_timeout_frames")]
820        timeout_frames: u32,
821    },
822    /// Click an interactive span (by `tag`) inside a `SelectableText` target after its computed
823    /// span bounds remain stable for `stable_frames`.
824    ///
825    /// This is intended for rich text surfaces where the clickable region is smaller than the
826    /// semantics node bounds (e.g. link spans inside a paragraph), and where clicking the center
827    /// of the node can miss the span.
828    ClickSelectableTextSpanStable {
829        #[serde(default, skip_serializing_if = "Option::is_none")]
830        window: Option<UiWindowTargetV1>,
831        #[serde(default, skip_serializing_if = "Option::is_none")]
832        pointer_kind: Option<UiPointerKindV1>,
833        target: UiSelectorV1,
834        tag: String,
835        #[serde(default)]
836        button: UiMouseButtonV1,
837        #[serde(
838            default = "default_click_count",
839            skip_serializing_if = "is_default_click_count"
840        )]
841        click_count: u8,
842        #[serde(default, skip_serializing_if = "Option::is_none")]
843        modifiers: Option<UiKeyModifiersV1>,
844        #[serde(default = "default_click_stable_frames")]
845        stable_frames: u32,
846        #[serde(default = "default_click_stable_max_move_px")]
847        max_move_px: f32,
848        #[serde(default = "default_action_timeout_frames")]
849        timeout_frames: u32,
850    },
851    /// Wait until a target's semantics bounds have remained stable for `stable_frames`.
852    ///
853    /// This is useful for overlays/virtualized surfaces where measured bounds can jump across
854    /// frames (estimate -> measured, placement flip/shift, scroll settle), and you want a
855    /// deterministic “ready” point without relying on wall-clock sleeps.
856    WaitBoundsStable {
857        #[serde(default, skip_serializing_if = "Option::is_none")]
858        window: Option<UiWindowTargetV1>,
859        target: UiSelectorV1,
860        #[serde(default = "default_bounds_stable_frames")]
861        stable_frames: u32,
862        #[serde(default = "default_bounds_stable_max_move_px")]
863        max_move_px: f32,
864        #[serde(default = "default_action_timeout_frames")]
865        timeout_frames: u32,
866    },
867    /// Wait until a target's structured semantics scroll field has remained stable for
868    /// `stable_frames`.
869    ///
870    /// This is intended for scrollable surfaces whose content extent converges across a few
871    /// post-layout frames (for example after switching tabs or appending content at the bottom).
872    WaitSemanticsScrollStable {
873        #[serde(default, skip_serializing_if = "Option::is_none")]
874        window: Option<UiWindowTargetV1>,
875        target: UiSelectorV1,
876        field: UiSemanticsScrollFieldV1,
877        #[serde(default = "default_semantics_scroll_stable_frames")]
878        stable_frames: u32,
879        #[serde(default = "default_semantics_scroll_stable_max_delta")]
880        max_delta: f64,
881        #[serde(default = "default_action_timeout_frames")]
882        timeout_frames: u32,
883    },
884    EnsureVisible {
885        #[serde(default, skip_serializing_if = "Option::is_none")]
886        window: Option<UiWindowTargetV1>,
887        target: UiSelectorV1,
888        #[serde(default)]
889        within_window: bool,
890        #[serde(default)]
891        padding_px: f32,
892        #[serde(default = "default_action_timeout_frames")]
893        timeout_frames: u32,
894    },
895    ScrollIntoView {
896        #[serde(default, skip_serializing_if = "Option::is_none")]
897        window: Option<UiWindowTargetV1>,
898        #[serde(default, skip_serializing_if = "Option::is_none")]
899        pointer_kind: Option<UiPointerKindV1>,
900        container: UiSelectorV1,
901        target: UiSelectorV1,
902        #[serde(default)]
903        delta_x: f32,
904        #[serde(default = "default_scroll_delta_y")]
905        delta_y: f32,
906        #[serde(default)]
907        require_fully_within_container: bool,
908        #[serde(default)]
909        require_fully_within_window: bool,
910        #[serde(default)]
911        padding_px: f32,
912        #[serde(default)]
913        padding_insets_px: Option<UiPaddingInsetsV1>,
914        #[serde(default = "default_action_timeout_frames")]
915        timeout_frames: u32,
916    },
917    TypeTextInto {
918        #[serde(default, skip_serializing_if = "Option::is_none")]
919        window: Option<UiWindowTargetV1>,
920        #[serde(default, skip_serializing_if = "Option::is_none")]
921        pointer_kind: Option<UiPointerKindV1>,
922        target: UiSelectorV1,
923        text: String,
924        #[serde(default)]
925        clear_before_type: bool,
926        #[serde(default = "default_action_timeout_frames")]
927        timeout_frames: u32,
928    },
929    /// Set the full text value of a target text surface via the accessibility SetValue path.
930    ///
931    /// Unlike [`UiActionStepV2::TypeTextInto`], this step does not depend on click-to-focus
932    /// behavior. It resolves the target from semantics, requests focus for that node, selects the
933    /// current text, then dispatches a single text input payload with `text`.
934    ///
935    /// Notes:
936    ///
937    /// - Intended for diagnostics gates that need stable text entry across policy-layer widgets.
938    /// - Targets should expose `actions.set_value=true`; disabled targets fail the step.
939    SetTextValue {
940        #[serde(default, skip_serializing_if = "Option::is_none")]
941        window: Option<UiWindowTargetV1>,
942        target: UiSelectorV1,
943        text: String,
944        #[serde(default = "default_action_timeout_frames")]
945        timeout_frames: u32,
946    },
947    /// Paste `text` into a target text surface by:
948    ///
949    /// 1) clicking the target to focus it,
950    /// 2) setting the OS clipboard text (best-effort),
951    /// 3) issuing the platform "paste" shortcut (`Primary+V`).
952    ///
953    /// This is intentionally higher-level than `set_clipboard_text + press_shortcut` so scripts
954    /// can gate paste-specific code paths with less boilerplate.
955    ///
956    /// Notes:
957    ///
958    /// - The clipboard write is best-effort and runner/platform dependent.
959    /// - `clear_before_paste` uses `SetTextSelection { anchor=0, focus=u32::MAX }` (not `Ctrl+A`).
960    PasteTextInto {
961        #[serde(default, skip_serializing_if = "Option::is_none")]
962        window: Option<UiWindowTargetV1>,
963        #[serde(default, skip_serializing_if = "Option::is_none")]
964        pointer_kind: Option<UiPointerKindV1>,
965        target: UiSelectorV1,
966        text: String,
967        #[serde(default)]
968        clear_before_paste: bool,
969        #[serde(default = "default_action_timeout_frames")]
970        timeout_frames: u32,
971    },
972    MenuSelect {
973        #[serde(default, skip_serializing_if = "Option::is_none")]
974        window: Option<UiWindowTargetV1>,
975        #[serde(default, skip_serializing_if = "Option::is_none")]
976        pointer_kind: Option<UiPointerKindV1>,
977        menu: UiSelectorV1,
978        item: UiSelectorV1,
979        #[serde(default = "default_action_timeout_frames")]
980        timeout_frames: u32,
981    },
982    MenuSelectPath {
983        #[serde(default, skip_serializing_if = "Option::is_none")]
984        window: Option<UiWindowTargetV1>,
985        #[serde(default, skip_serializing_if = "Option::is_none")]
986        pointer_kind: Option<UiPointerKindV1>,
987        path: Vec<UiSelectorV1>,
988        #[serde(default = "default_action_timeout_frames")]
989        timeout_frames: u32,
990    },
991    DragTo {
992        #[serde(default, skip_serializing_if = "Option::is_none")]
993        window: Option<UiWindowTargetV1>,
994        #[serde(default, skip_serializing_if = "Option::is_none")]
995        pointer_kind: Option<UiPointerKindV1>,
996        from: UiSelectorV1,
997        to: UiSelectorV1,
998        #[serde(default)]
999        button: UiMouseButtonV1,
1000        #[serde(default = "default_drag_steps")]
1001        steps: u32,
1002        #[serde(default = "default_action_timeout_frames")]
1003        timeout_frames: u32,
1004    },
1005    SetSliderValue {
1006        #[serde(default, skip_serializing_if = "Option::is_none")]
1007        window: Option<UiWindowTargetV1>,
1008        #[serde(default, skip_serializing_if = "Option::is_none")]
1009        pointer_kind: Option<UiPointerKindV1>,
1010        target: UiSelectorV1,
1011        value: f32,
1012        #[serde(default = "default_slider_min")]
1013        min: f32,
1014        #[serde(default = "default_slider_max")]
1015        max: f32,
1016        #[serde(default = "default_slider_epsilon")]
1017        epsilon: f32,
1018        #[serde(default = "default_action_timeout_frames")]
1019        timeout_frames: u32,
1020        #[serde(default = "default_drag_steps")]
1021        drag_steps: u32,
1022    },
1023    SetWindowInnerSize {
1024        #[serde(default, skip_serializing_if = "Option::is_none")]
1025        window: Option<UiWindowTargetV1>,
1026        width_px: f32,
1027        height_px: f32,
1028    },
1029    /// Best-effort request to update OS window style facets at runtime (patch semantics).
1030    ///
1031    /// This is intended for diagnostics-only repros and regression gates for utility window
1032    /// postures (frameless/transparent/materials/hit-test policies).
1033    ///
1034    /// Capability-gated behind `diag.window_style_patch_v1`.
1035    ///
1036    /// Note: as of 2026-03-04 this capability is Windows-only in the default in-tree runner.
1037    /// Supported patch fields in the default runner are currently limited to:
1038    /// - `z_level`
1039    /// - `background_material`
1040    /// - `hit_test`
1041    /// - `opacity_alpha_u8`
1042    SetWindowStyle {
1043        #[serde(default, skip_serializing_if = "Option::is_none")]
1044        window: Option<UiWindowTargetV1>,
1045        style: UiWindowStylePatchV1,
1046    },
1047    SetWindowInsets {
1048        #[serde(default)]
1049        safe_area_insets: UiInsetsOverrideV1,
1050        #[serde(default)]
1051        occlusion_insets: UiInsetsOverrideV1,
1052    },
1053    /// Diagnostics-only clipboard override to simulate clipboard read denial/unavailability.
1054    ///
1055    /// This is intended to gate “paste request fails gracefully” behavior under mobile privacy
1056    /// constraints without requiring a real mobile runner.
1057    SetClipboardForceUnavailable {
1058        enabled: bool,
1059    },
1060    /// Set the OS clipboard text payload (best-effort).
1061    ///
1062    /// This is intended to make "paste" flows deterministic in scripted diagnostics by ensuring
1063    /// the clipboard contents are known.
1064    ///
1065    /// Requires capability `diag.clipboard_text`.
1066    SetClipboardText {
1067        text: String,
1068    },
1069    /// Wait until a clipboard write completion matching `outcome` is observed.
1070    ///
1071    /// This is intended for gating explicit copy-button success/failure without inferring from
1072    /// clipboard contents alone.
1073    ///
1074    /// When `outcome = "failure"`, callers may additionally match on `error_kind` and/or a
1075    /// substring of the structured error message.
1076    WaitClipboardWriteResult {
1077        outcome: UiClipboardWriteResultV1,
1078        #[serde(default, skip_serializing_if = "Option::is_none")]
1079        error_kind: Option<UiClipboardAccessErrorKindV1>,
1080        #[serde(default, skip_serializing_if = "Option::is_none")]
1081        message_contains: Option<String>,
1082        #[serde(default = "default_action_timeout_frames")]
1083        timeout_frames: u32,
1084    },
1085    /// Assert a clipboard write completion matches `outcome`.
1086    ///
1087    /// If a preceding `wait_clipboard_write_result` step already captured a completion, this step
1088    /// asserts against that cached result. Otherwise it waits for a new completion up to
1089    /// `timeout_frames`, which keeps the step usable as a single-shot gate after clicking a copy
1090    /// button.
1091    ///
1092    /// When `outcome = "failure"`, callers may additionally match on `error_kind` and/or a
1093    /// substring of the structured error message.
1094    AssertClipboardWriteResult {
1095        outcome: UiClipboardWriteResultV1,
1096        #[serde(default, skip_serializing_if = "Option::is_none")]
1097        error_kind: Option<UiClipboardAccessErrorKindV1>,
1098        #[serde(default, skip_serializing_if = "Option::is_none")]
1099        message_contains: Option<String>,
1100        #[serde(default = "default_action_timeout_frames")]
1101        timeout_frames: u32,
1102    },
1103    /// Assert that the OS clipboard text payload equals `text` (best-effort).
1104    ///
1105    /// This is intended to make clipboard-driven regression scripts explainable without relying
1106    /// on screenshots.
1107    ///
1108    /// Requires capability `diag.clipboard_text`.
1109    AssertClipboardText {
1110        text: String,
1111        #[serde(default = "default_action_timeout_frames")]
1112        timeout_frames: u32,
1113    },
1114    /// In-app inspector helper: open help, search for `query`, lock the best match, and request
1115    /// copying the best selector JSON to the OS clipboard.
1116    ///
1117    /// This is intended to gate that the in-app inspector UX is still functional under
1118    /// tool-launched scripted runs (`fretboard diag run/suite --launch`) without relying on
1119    /// keyboard shortcut injection.
1120    ///
1121    /// Behavior notes:
1122    ///
1123    /// - Matching prefers `test_id`, then `label` when text redaction is disabled.
1124    /// - The runtime may keep the help overlay open after the step.
1125    InspectHelpLockBestMatchAndCopySelector {
1126        #[serde(default, skip_serializing_if = "Option::is_none")]
1127        window: Option<UiWindowTargetV1>,
1128        query: String,
1129        #[serde(default = "default_action_timeout_frames")]
1130        timeout_frames: u32,
1131    },
1132    /// In-app inspector helper: open help, ensure the semantics tree panel is visible, select the
1133    /// best match for `query` in the tree, lock it, and copy the best selector JSON to the OS
1134    /// clipboard.
1135    ///
1136    /// This is intended to gate that the help-mode tree browser remains functional under
1137    /// tool-launched scripted runs (`fretboard diag run/suite --launch`) without relying on
1138    /// keyboard shortcut injection.
1139    InspectHelpTreeLockBestMatchAndCopySelector {
1140        #[serde(default, skip_serializing_if = "Option::is_none")]
1141        window: Option<UiWindowTargetV1>,
1142        query: String,
1143        #[serde(default = "default_action_timeout_frames")]
1144        timeout_frames: u32,
1145    },
1146    /// Diagnostics-only incoming-open injection (best-effort).
1147    ///
1148    /// This simulates “open in…” / share-target flows by injecting an `IncomingOpenRequest` event.
1149    InjectIncomingOpen {
1150        items: Vec<UiIncomingOpenInjectItemV1>,
1151    },
1152    /// Set the OS window outer position (screen-space logical pixels).
1153    ///
1154    /// This is intended for deterministically arranging windows in scripted repros and for
1155    /// best-effort placement restoration (ADR 0017).
1156    SetWindowOuterPosition {
1157        #[serde(default, skip_serializing_if = "Option::is_none")]
1158        window: Option<UiWindowTargetV1>,
1159        x_px: f32,
1160        y_px: f32,
1161    },
1162    /// Set a runner-level cursor screen position override (screen-space physical pixels).
1163    ///
1164    /// Desktop runners may use this during scripted diagnostics to drive hover routing that is
1165    /// normally owned by OS cursor events (e.g. cross-window docking).
1166    ///
1167    /// Requires capability `diag.cursor_screen_pos_override`.
1168    SetCursorScreenPos {
1169        x_px: f32,
1170        y_px: f32,
1171    },
1172    /// Set a runner-level cursor screen position override using window-local client coordinates.
1173    ///
1174    /// This is intended for cross-window scripted diagnostics where the runner must synthesize a
1175    /// global cursor location from window-local input.
1176    ///
1177    /// Coordinates are in window-client **physical pixels**.
1178    ///
1179    /// Requires capability `diag.cursor_screen_pos_override`.
1180    SetCursorInWindow {
1181        #[serde(default, skip_serializing_if = "Option::is_none")]
1182        window: Option<UiWindowTargetV1>,
1183        x_px: f32,
1184        y_px: f32,
1185    },
1186    /// Set a runner-level cursor screen position override using window-local client coordinates.
1187    ///
1188    /// This is identical to `set_cursor_in_window`, except the coordinates are in window-client
1189    /// **logical pixels** (pre-DPI scale). The runner converts to physical pixels using the
1190    /// current window scale factor.
1191    ///
1192    /// Prefer this for deterministic scripts that already express geometry in logical pixels.
1193    ///
1194    /// Requires capability `diag.cursor_screen_pos_override`.
1195    SetCursorInWindowLogical {
1196        #[serde(default, skip_serializing_if = "Option::is_none")]
1197        window: Option<UiWindowTargetV1>,
1198        x_px: f32,
1199        y_px: f32,
1200    },
1201    /// Set a runner-level mouse button state override.
1202    ///
1203    /// This is intended for scripted diagnostics that need to exercise runner-level fallback
1204    /// behavior that depends on OS button state (e.g. "release outside all windows" poll-up
1205    /// paths) without requiring real OS input.
1206    ///
1207    /// Desktop runners may choose to apply this only while certain interactions are active
1208    /// (e.g. cross-window dock drags).
1209    SetMouseButtons {
1210        #[serde(default, skip_serializing_if = "Option::is_none")]
1211        window: Option<UiWindowTargetV1>,
1212        #[serde(default, skip_serializing_if = "Option::is_none")]
1213        left: Option<bool>,
1214        #[serde(default, skip_serializing_if = "Option::is_none")]
1215        right: Option<bool>,
1216        #[serde(default, skip_serializing_if = "Option::is_none")]
1217        middle: Option<bool>,
1218    },
1219    RaiseWindow {
1220        #[serde(default, skip_serializing_if = "Option::is_none")]
1221        window: Option<UiWindowTargetV1>,
1222    },
1223    /// Drag with pointer down across frames until `predicate` passes, or timeout.
1224    ///
1225    /// This is intended for runner-owned cross-window routing: scripts can keep a drag session
1226    /// active while polling diagnostics predicates that are only updated between frames.
1227    DragPointerUntil {
1228        #[serde(default, skip_serializing_if = "Option::is_none")]
1229        window: Option<UiWindowTargetV1>,
1230        #[serde(default, skip_serializing_if = "Option::is_none")]
1231        pointer_kind: Option<UiPointerKindV1>,
1232        target: UiSelectorV1,
1233        #[serde(default)]
1234        button: UiMouseButtonV1,
1235        #[serde(default = "default_true")]
1236        release_on_success: bool,
1237        delta_x: f32,
1238        delta_y: f32,
1239        #[serde(default = "default_drag_steps")]
1240        steps: u32,
1241        predicate: UiPredicateV1,
1242        #[serde(default = "default_action_timeout_frames")]
1243        timeout_frames: u32,
1244    },
1245}
1246
1247impl From<UiActionStepV1> for UiActionStepV2 {
1248    fn from(value: UiActionStepV1) -> Self {
1249        match value {
1250            UiActionStepV1::Click {
1251                target,
1252                button,
1253                click_count,
1254            } => Self::Click {
1255                window: None,
1256                pointer_kind: None,
1257                target,
1258                button,
1259                click_count,
1260                modifiers: None,
1261            },
1262            UiActionStepV1::ResetDiagnostics => Self::ResetDiagnostics,
1263            UiActionStepV1::MovePointer { window, target } => Self::MovePointer {
1264                window,
1265                pointer_kind: None,
1266                target,
1267            },
1268            UiActionStepV1::DragPointer {
1269                target,
1270                button,
1271                delta_x,
1272                delta_y,
1273                steps,
1274            } => Self::DragPointer {
1275                window: None,
1276                pointer_kind: None,
1277                target,
1278                button,
1279                clamp_to_window_bounds: true,
1280                delta_x,
1281                delta_y,
1282                steps,
1283            },
1284            UiActionStepV1::Wheel {
1285                target,
1286                delta_x,
1287                delta_y,
1288            } => Self::Wheel {
1289                window: None,
1290                pointer_kind: None,
1291                target,
1292                delta_x,
1293                delta_y,
1294            },
1295            UiActionStepV1::PressKey {
1296                key,
1297                modifiers,
1298                repeat,
1299            } => Self::PressKey {
1300                key,
1301                modifiers,
1302                repeat,
1303            },
1304            UiActionStepV1::TypeText { text } => Self::TypeText { text },
1305            UiActionStepV1::WaitFrames { n } => Self::WaitFrames { window: None, n },
1306            UiActionStepV1::WaitUntil {
1307                predicate,
1308                timeout_frames,
1309            } => Self::WaitUntil {
1310                window: None,
1311                predicate,
1312                timeout_frames,
1313                timeout_ms: None,
1314            },
1315            UiActionStepV1::Assert { predicate } => Self::Assert {
1316                window: None,
1317                predicate,
1318            },
1319            UiActionStepV1::CaptureBundle {
1320                label,
1321                max_snapshots,
1322            } => Self::CaptureBundle {
1323                label,
1324                max_snapshots,
1325            },
1326            UiActionStepV1::CaptureScreenshot {
1327                label,
1328                timeout_frames,
1329            } => Self::CaptureScreenshot {
1330                label,
1331                timeout_frames,
1332                timeout_ms: None,
1333            },
1334        }
1335    }
1336}
1337
1338fn default_drag_steps() -> u32 {
1339    8
1340}
1341
1342fn is_default_drag_steps(v: &u32) -> bool {
1343    *v == default_drag_steps()
1344}
1345
1346fn default_wheel_burst_count() -> u32 {
1347    8
1348}
1349
1350fn is_default_wheel_burst_count(v: &u32) -> bool {
1351    *v == default_wheel_burst_count()
1352}
1353
1354fn default_move_frames_per_step() -> u32 {
1355    1
1356}
1357
1358fn default_click_count() -> u8 {
1359    1
1360}
1361
1362fn is_default_click_count(v: &u8) -> bool {
1363    *v == 1
1364}
1365
1366fn default_long_press_duration_ms() -> u64 {
1367    500
1368}
1369
1370fn is_default_long_press_duration_ms(v: &u64) -> bool {
1371    *v == 500
1372}
1373
1374fn default_click_stable_frames() -> u32 {
1375    2
1376}
1377
1378fn default_click_stable_max_move_px() -> f32 {
1379    1.0
1380}
1381
1382fn default_bounds_stable_frames() -> u32 {
1383    2
1384}
1385
1386fn default_bounds_stable_max_move_px() -> f32 {
1387    1.0
1388}
1389
1390fn default_semantics_scroll_stable_frames() -> u32 {
1391    2
1392}
1393
1394fn default_semantics_scroll_stable_max_delta() -> f64 {
1395    1.0
1396}
1397
1398fn default_capture_screenshot_timeout_frames() -> u32 {
1399    300
1400}
1401
1402fn default_action_timeout_frames() -> u32 {
1403    180
1404}
1405
1406fn default_true() -> bool {
1407    true
1408}
1409
1410fn default_scroll_delta_y() -> f32 {
1411    -120.0
1412}
1413
1414fn default_slider_min() -> f32 {
1415    0.0
1416}
1417
1418fn default_slider_max() -> f32 {
1419    100.0
1420}
1421
1422fn default_slider_epsilon() -> f32 {
1423    0.5
1424}
1425
1426#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1427#[serde(rename_all = "snake_case")]
1428pub enum UiMouseButtonV1 {
1429    #[default]
1430    Left,
1431    Right,
1432    Middle,
1433}
1434
1435impl UiMouseButtonV1 {
1436    pub fn from_button(button: fret_core::MouseButton) -> Self {
1437        match button {
1438            fret_core::MouseButton::Left => Self::Left,
1439            fret_core::MouseButton::Right => Self::Right,
1440            fret_core::MouseButton::Middle => Self::Middle,
1441            fret_core::MouseButton::Back
1442            | fret_core::MouseButton::Forward
1443            | fret_core::MouseButton::Other(_) => Self::Left,
1444        }
1445    }
1446}
1447
1448#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1449#[serde(rename_all = "snake_case")]
1450pub enum UiPointerKindV1 {
1451    #[default]
1452    Mouse,
1453    Touch,
1454    Pen,
1455}
1456
1457#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
1458pub struct UiKeyModifiersV1 {
1459    #[serde(default)]
1460    pub shift: bool,
1461    #[serde(default)]
1462    pub ctrl: bool,
1463    #[serde(default)]
1464    pub alt: bool,
1465    #[serde(default)]
1466    pub meta: bool,
1467}
1468
1469impl UiKeyModifiersV1 {
1470    pub fn from_modifiers(modifiers: fret_core::Modifiers) -> Self {
1471        Self {
1472            shift: modifiers.shift,
1473            ctrl: modifiers.ctrl,
1474            alt: modifiers.alt,
1475            meta: modifiers.meta,
1476        }
1477    }
1478}
1479
1480#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1481#[serde(rename_all = "snake_case")]
1482pub enum UiWindowDecorationsRequestV1 {
1483    System,
1484    None,
1485    Server,
1486    Client,
1487}
1488
1489#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1490#[serde(rename_all = "snake_case")]
1491pub enum UiTaskbarVisibilityV1 {
1492    Show,
1493    Hide,
1494}
1495
1496#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1497#[serde(rename_all = "snake_case")]
1498pub enum UiActivationPolicyV1 {
1499    Activates,
1500    NonActivating,
1501}
1502
1503#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1504#[serde(rename_all = "snake_case")]
1505pub enum UiWindowZLevelV1 {
1506    Normal,
1507    AlwaysOnTop,
1508}
1509
1510#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1511#[serde(rename_all = "snake_case")]
1512pub enum UiWindowHitTestRequestV1 {
1513    Normal,
1514    PassthroughAll,
1515    PassthroughRegions,
1516}
1517
1518#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1519#[serde(rename_all = "snake_case")]
1520pub enum UiWindowAppearanceV1 {
1521    Opaque,
1522    CompositedNoBackdrop,
1523    CompositedBackdrop,
1524}
1525
1526#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1527#[serde(rename_all = "snake_case")]
1528pub enum UiWindowBackgroundMaterialRequestV1 {
1529    None,
1530    SystemDefault,
1531    Mica,
1532    Acrylic,
1533    Vibrancy,
1534}
1535
1536#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1537pub struct UiWindowStyleMatchV1 {
1538    #[serde(default, skip_serializing_if = "Option::is_none")]
1539    pub decorations: Option<UiWindowDecorationsRequestV1>,
1540    #[serde(default, skip_serializing_if = "Option::is_none")]
1541    pub resizable: Option<bool>,
1542    #[serde(default, skip_serializing_if = "Option::is_none")]
1543    pub transparent: Option<bool>,
1544    #[serde(default, skip_serializing_if = "Option::is_none")]
1545    pub visual_transparent: Option<bool>,
1546    #[serde(default, skip_serializing_if = "Option::is_none")]
1547    pub appearance: Option<UiWindowAppearanceV1>,
1548    #[serde(default, skip_serializing_if = "Option::is_none")]
1549    pub taskbar: Option<UiTaskbarVisibilityV1>,
1550    #[serde(default, skip_serializing_if = "Option::is_none")]
1551    pub activation: Option<UiActivationPolicyV1>,
1552    #[serde(default, skip_serializing_if = "Option::is_none")]
1553    pub z_level: Option<UiWindowZLevelV1>,
1554    #[serde(default, skip_serializing_if = "Option::is_none")]
1555    pub hit_test: Option<UiWindowHitTestRequestV1>,
1556    #[serde(default, skip_serializing_if = "Option::is_none")]
1557    pub hit_test_regions_fingerprint64: Option<u64>,
1558}
1559
1560#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1561pub struct UiWindowStylePatchV1 {
1562    #[serde(default, skip_serializing_if = "Option::is_none")]
1563    pub taskbar: Option<UiTaskbarVisibilityV1>,
1564    #[serde(default, skip_serializing_if = "Option::is_none")]
1565    pub activation: Option<UiActivationPolicyV1>,
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    pub z_level: Option<UiWindowZLevelV1>,
1568    #[serde(default, skip_serializing_if = "Option::is_none")]
1569    pub decorations: Option<UiWindowDecorationsRequestV1>,
1570    #[serde(default, skip_serializing_if = "Option::is_none")]
1571    pub resizable: Option<bool>,
1572    #[serde(default, skip_serializing_if = "Option::is_none")]
1573    pub transparent: Option<bool>,
1574    #[serde(default, skip_serializing_if = "Option::is_none")]
1575    pub background_material: Option<UiWindowBackgroundMaterialRequestV1>,
1576    #[serde(default, skip_serializing_if = "Option::is_none")]
1577    pub hit_test: Option<UiWindowHitTestPatchV1>,
1578    /// Global window opacity hint (0..=255), best-effort.
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub opacity_alpha_u8: Option<u8>,
1581}
1582
1583#[derive(Debug, Clone, Serialize, Deserialize)]
1584#[serde(tag = "kind", rename_all = "snake_case")]
1585pub enum UiWindowHitTestPatchV1 {
1586    Normal,
1587    PassthroughAll,
1588    PassthroughRegions {
1589        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1590        regions: Vec<UiWindowHitTestRegionV1>,
1591    },
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize)]
1595#[serde(tag = "kind", rename_all = "snake_case")]
1596pub enum UiWindowHitTestRegionV1 {
1597    Rect {
1598        x: f32,
1599        y: f32,
1600        width: f32,
1601        height: f32,
1602    },
1603    RRect {
1604        x: f32,
1605        y: f32,
1606        width: f32,
1607        height: f32,
1608        radius: f32,
1609    },
1610}
1611
1612#[derive(Debug, Clone, Serialize, Deserialize)]
1613#[serde(tag = "kind", rename_all = "snake_case")]
1614pub enum UiPredicateV1 {
1615    Exists {
1616        target: UiSelectorV1,
1617    },
1618    NotExists {
1619        target: UiSelectorV1,
1620    },
1621    /// True when `target` resolves to a node that is a descendant of (or equal to) `scope`.
1622    ///
1623    /// This is primarily used to disambiguate selectors when multiple similar widgets exist in a
1624    /// window (and to keep scripts resilient under overlay/root splits).
1625    ExistsUnder {
1626        scope: UiSelectorV1,
1627        target: UiSelectorV1,
1628    },
1629    /// True when `scope` exists and `target` does *not* exist under it.
1630    ///
1631    /// Note: if `scope` does not exist, this predicate returns false.
1632    NotExistsUnder {
1633        scope: UiSelectorV1,
1634        target: UiSelectorV1,
1635    },
1636    /// True when the currently focused semantics node equals `target` and is a descendant of
1637    /// (or equal to) `scope`.
1638    ///
1639    /// This is a convenience predicate for focus-trap / focus-restore assertions:
1640    /// - "focus stays within this dialog root"
1641    /// - "after open, focus moves to the dialog's close button"
1642    FocusedDescendantIs {
1643        scope: UiSelectorV1,
1644        target: UiSelectorV1,
1645    },
1646    FocusIs {
1647        target: UiSelectorV1,
1648    },
1649    RoleIs {
1650        target: UiSelectorV1,
1651        role: String,
1652    },
1653    /// True when the target exists and its semantics `label` contains `text` as a substring.
1654    LabelContains {
1655        target: UiSelectorV1,
1656        text: String,
1657    },
1658    /// True when the target exists and its semantics `label` length (UTF-8 bytes) matches `len_bytes`.
1659    ///
1660    /// This is intended to remain stable under diagnostics text redaction, where labels may be
1661    /// replaced with placeholders like `<redacted len=123>`.
1662    LabelLenIs {
1663        target: UiSelectorV1,
1664        len_bytes: u32,
1665    },
1666    /// True when the target exists and its semantics `label` length (UTF-8 bytes) is at least `min_len_bytes`.
1667    LabelLenGe {
1668        target: UiSelectorV1,
1669        min_len_bytes: u32,
1670    },
1671    /// True when the target exists and its semantics `value` contains `text` as a substring.
1672    ValueContains {
1673        target: UiSelectorV1,
1674        text: String,
1675    },
1676    /// True when the target exists and its semantics `value` equals `text`.
1677    ///
1678    /// Caution: some widgets use locale-dependent `value` strings; prefer structured predicates
1679    /// (`SemanticsNumericApproxEq`, `SemanticsScrollApproxEq`, ...) when available.
1680    ValueEquals {
1681        target: UiSelectorV1,
1682        text: String,
1683    },
1684    /// True when the target exists and its semantics `value` length (UTF-8 bytes) matches `len_bytes`.
1685    ///
1686    /// This is intended to remain stable under diagnostics text redaction, where values may be
1687    /// replaced with placeholders like `<redacted len=123>`.
1688    ValueLenIs {
1689        target: UiSelectorV1,
1690        len_bytes: u32,
1691    },
1692    /// True when the target exists and its semantics `value` length (UTF-8 bytes) is at least `min_len_bytes`.
1693    ValueLenGe {
1694        target: UiSelectorV1,
1695        min_len_bytes: u32,
1696    },
1697    /// True when the target exists and its semantics `pos_in_set` equals `pos_in_set`.
1698    PosInSetIs {
1699        target: UiSelectorV1,
1700        pos_in_set: u32,
1701    },
1702    /// True when the target exists and its semantics `set_size` equals `set_size`.
1703    SetSizeIs {
1704        target: UiSelectorV1,
1705        set_size: u32,
1706    },
1707    CheckedIs {
1708        target: UiSelectorV1,
1709        checked: bool,
1710    },
1711    SelectedIs {
1712        target: UiSelectorV1,
1713        selected: bool,
1714    },
1715    /// True when the target exists and its structured semantics numeric field is approximately
1716    /// equal to the specified value.
1717    ///
1718    /// This is intended for range controls (slider/progress-like semantics) which should prefer
1719    /// `SemanticsNode.extra.numeric.*` over locale-dependent `value` strings.
1720    SemanticsNumericApproxEq {
1721        target: UiSelectorV1,
1722        field: UiSemanticsNumericFieldV1,
1723        value: f64,
1724        #[serde(default)]
1725        eps: f64,
1726    },
1727    /// True when the target exists and its structured semantics scroll field is present and finite.
1728    ///
1729    /// This is a lightweight gate to ensure `SemanticsNode.extra.scroll.*` is emitted for scroll
1730    /// containers.
1731    SemanticsScrollIsFinite {
1732        target: UiSelectorV1,
1733        field: UiSemanticsScrollFieldV1,
1734    },
1735    /// True when the target exists and its structured semantics scroll field is approximately
1736    /// equal to the specified value.
1737    SemanticsScrollApproxEq {
1738        target: UiSelectorV1,
1739        field: UiSemanticsScrollFieldV1,
1740        value: f64,
1741        #[serde(default)]
1742        eps: f64,
1743    },
1744    /// True when the target exists and its structured semantics scroll field is not approximately
1745    /// equal to the specified value.
1746    SemanticsScrollNotApproxEq {
1747        target: UiSelectorV1,
1748        field: UiSemanticsScrollFieldV1,
1749        value: f64,
1750        #[serde(default)]
1751        eps: f64,
1752    },
1753    /// True when the target exists and its semantics reports whether it currently has an IME
1754    /// composition range.
1755    ///
1756    /// Notes:
1757    /// - This checks whether `SemanticsNode.text_composition` is `Some(_)`.
1758    /// - Some platforms/widgets may omit composition ranges even while composing; treat this
1759    ///   predicate as best-effort and gate it behind appropriate suites.
1760    TextCompositionIs {
1761        target: UiSelectorV1,
1762        composing: bool,
1763    },
1764    /// True when the diagnostics runtime has a window-level IME cursor area snapshot.
1765    ///
1766    /// Notes:
1767    /// - This reads `WindowTextInputSnapshot.ime_cursor_area`.
1768    /// - Coordinates are window logical pixels.
1769    ImeCursorAreaIsSome {
1770        is_some: bool,
1771    },
1772    /// True when the window-level IME cursor area snapshot is within the current window bounds.
1773    ///
1774    /// This is a coarse regression gate for IME geometry bugs (caret/candidate window
1775    /// teleportation, negative coordinates, far-offscreen rects).
1776    ImeCursorAreaWithinWindow {
1777        #[serde(default)]
1778        padding_px: f32,
1779        /// Optional per-edge padding (added on top of `padding_px`).
1780        #[serde(default, skip_serializing_if = "Option::is_none")]
1781        padding_insets_px: Option<UiPaddingInsetsV1>,
1782        #[serde(default)]
1783        eps_px: f32,
1784    },
1785    /// True when the window-level IME cursor area snapshot has at least the specified size.
1786    ///
1787    /// This can catch "zero rect" bugs where the IME caret geometry is missing meaningful size.
1788    ImeCursorAreaMinSize {
1789        #[serde(default)]
1790        min_w_px: f32,
1791        #[serde(default)]
1792        min_h_px: f32,
1793        #[serde(default)]
1794        eps_px: f32,
1795    },
1796    /// True when the diagnostics runtime has a window-level IME surrounding text excerpt.
1797    ///
1798    /// Notes:
1799    /// - This reads `WindowTextInputSnapshot.surrounding_text`.
1800    /// - Offsets are UTF-8 byte offsets within the excerpt and should be on char boundaries.
1801    ImeSurroundingTextIsSome {
1802        is_some: bool,
1803    },
1804    /// True when the window-level IME surrounding text excerpt is present and internally valid.
1805    ///
1806    /// This is a coarse regression gate for platform text-input interop (winit `ImeSurroundingText`
1807    /// constraints: max bytes, offsets within range, char boundaries).
1808    ImeSurroundingTextValid,
1809    CheckedIsNone {
1810        target: UiSelectorV1,
1811    },
1812    /// True when the current active item is the specified `item`.
1813    ///
1814    /// This supports both common semantics models:
1815    ///
1816    /// - Composite widgets that retain focus on a container and express the highlighted row via
1817    ///   `active_descendant` (DOM-style `aria-activedescendant`).
1818    /// - Widgets that use roving focus (the focused node itself is the active item).
1819    ActiveItemIs {
1820        /// Container node (e.g. listbox). Used when the widget uses `active_descendant`.
1821        container: UiSelectorV1,
1822        /// The expected active item (highlighted option / row).
1823        item: UiSelectorV1,
1824    },
1825    /// True when there is no active item (neither roving focus nor `active_descendant`).
1826    ///
1827    /// This is primarily intended for combobox/listbox recipes that should not implicitly
1828    /// highlight the first option on open unless `auto_highlight` is enabled.
1829    ActiveItemIsNone {
1830        /// Container node used for composite focus + `active_descendant` models (typically the
1831        /// focused input or listbox root).
1832        container: UiSelectorV1,
1833    },
1834    BarrierRoots {
1835        #[serde(default)]
1836        barrier_root: UiOptionalRootStateV1,
1837        #[serde(default)]
1838        focus_barrier_root: UiOptionalRootStateV1,
1839        #[serde(default, skip_serializing_if = "Option::is_none")]
1840        require_equal: Option<bool>,
1841    },
1842    RenderTextMissingGlyphsIs {
1843        missing_glyphs: u64,
1844    },
1845    /// Ensures that when the renderer reports missing/tofu glyphs for the current frame, a
1846    /// renderer-owned font fallback trace has been captured and is non-empty.
1847    ///
1848    /// This predicate is meant to keep "tofu regressions" debuggable: if missing glyphs happen,
1849    /// the diagnostics bundle should contain an audit trail of the selected families.
1850    RenderTextFontTraceCapturedWhenMissingGlyphs,
1851    /// True when the runner-owned `TextFontStackKey` has not changed for `stable_frames`
1852    /// consecutive frames.
1853    ///
1854    /// This is primarily used to keep perf suites from including one-time system font catalog
1855    /// rescans (which bump `TextFontStackKey` and can trigger large relayouts) inside a measured
1856    /// window.
1857    TextFontStackKeyStable {
1858        stable_frames: u32,
1859    },
1860    /// True when the runner-owned `FontCatalog` has been populated with at least one family.
1861    ///
1862    /// On desktop, the runner may seed an empty catalog at startup and populate it asynchronously
1863    /// via the system font rescan pipeline. This predicate lets scripts wait for that one-time
1864    /// async work to complete before entering a measured window.
1865    FontCatalogPopulated,
1866    /// True when the runner-owned system font rescan pipeline is idle (no work in flight and no
1867    /// pending restart).
1868    ///
1869    /// Desktop runners may perform a one-time async system font rescan at startup. Applying the
1870    /// result can bump `TextFontStackKey` and trigger large relayouts; this predicate lets perf
1871    /// suites wait for that one-time work to complete before entering a measured window.
1872    SystemFontRescanIdle,
1873    /// True when `debug.resource_loading.asset_load.missing_bundle_asset_requests >= min`.
1874    ///
1875    /// This is intended for negative-path diagnostics scripts that deliberately trigger missing
1876    /// bundle assets and want a structured gate instead of grepping logs.
1877    AssetLoadMissingBundleAssetRequestsGe {
1878        min: u64,
1879    },
1880    /// True when `debug.resource_loading.asset_load.stale_manifest_requests >= min`.
1881    ///
1882    /// This is intended for native/package-dev file-backed manifest lanes where the logical
1883    /// bundle/key mapping still exists but the manifest-backed file path has gone stale.
1884    AssetLoadStaleManifestRequestsGe {
1885        min: u64,
1886    },
1887    /// True when `debug.resource_loading.asset_load.unsupported_file_requests >= min`.
1888    ///
1889    /// This is intended to gate portable capability degradations where file locators must stay
1890    /// unsupported on targets like wasm.
1891    AssetLoadUnsupportedFileRequestsGe {
1892        min: u64,
1893    },
1894    /// True when `debug.resource_loading.asset_load.unsupported_url_requests >= min`.
1895    AssetLoadUnsupportedUrlRequestsGe {
1896        min: u64,
1897    },
1898    /// True when
1899    /// `debug.resource_loading.asset_load.external_reference_unavailable_requests >= min`.
1900    ///
1901    /// This is intended for byte-only asset surfaces that should not silently claim an external
1902    /// reference exists.
1903    AssetLoadExternalReferenceUnavailableRequestsGe {
1904        min: u64,
1905    },
1906    /// True when `debug.resource_loading.asset_load.revision_change_requests >= min`.
1907    ///
1908    /// This is intended for hot-reload / invalidation flows where we want to observe that a
1909    /// locator revision actually changed across snapshots.
1910    AssetLoadRevisionChangeRequestsGe {
1911        min: u64,
1912    },
1913    /// True when `debug.resource_loading.asset_load.recent[*].outcome_kind` contains
1914    /// `outcome_kind`.
1915    ///
1916    /// Supported values currently mirror the debug snapshot surface:
1917    /// - `resolved`
1918    /// - `missing`
1919    /// - `stale_manifest`
1920    /// - `unsupported_locator_kind`
1921    /// - `external_reference_unavailable`
1922    /// - `resolver_unavailable`
1923    /// - `access_denied`
1924    /// - `message`
1925    AssetLoadRecentOutcomeSeen {
1926        outcome_kind: String,
1927    },
1928    /// True when `debug.resource_loading.asset_load.recent[*].revision_transition` contains
1929    /// `transition`.
1930    ///
1931    /// Supported values currently mirror the debug snapshot surface:
1932    /// - `initial`
1933    /// - `stable`
1934    /// - `changed`
1935    AssetLoadRecentRevisionTransitionSeen {
1936        transition: String,
1937    },
1938    /// True when `debug.resource_loading.font_environment.bundled_baseline_source == source`.
1939    ///
1940    /// Supported values currently mirror the debug snapshot surface:
1941    /// - `none`
1942    /// - `bundled_profile`
1943    BundledFontBaselineSourceIs {
1944        source: String,
1945    },
1946    /// True when `debug.resource_loading.font_environment.renderer_font_environment_revision >= min`.
1947    RendererFontEnvironmentRevisionGe {
1948        min: u64,
1949    },
1950    /// True when `debug.resource_loading.font_environment.renderer_font_sources[*].source_lane`
1951    /// contains `lane`.
1952    ///
1953    /// Supported values currently mirror the debug snapshot surface:
1954    /// - `bundled_startup`
1955    /// - `asset_request`
1956    RendererFontSourceLaneSeen {
1957        lane: String,
1958    },
1959    /// True when `debug.resource_loading.font_environment.renderer_font_sources[*].asset_key`
1960    /// contains `asset_key`.
1961    RendererFontSourceAssetKeySeen {
1962        asset_key: String,
1963    },
1964    /// True when `debug.resource_loading.svg_text_bridge.selection_misses.len() >= min`.
1965    SvgTextBridgeSelectionMissesGe {
1966        min: u64,
1967    },
1968    /// True when `debug.resource_loading.svg_text_bridge.missing_glyphs.len() >= min`.
1969    SvgTextBridgeMissingGlyphsGe {
1970        min: u64,
1971    },
1972    /// True when `debug.resource_loading.svg_text_bridge` reports a clean bridge result.
1973    ///
1974    /// A clean result means there were no font-selection misses and no missing glyph records.
1975    SvgTextBridgeDiagnosticsCleanIs {
1976        clean: bool,
1977    },
1978    /// True when `debug.resource_loading.svg_text_bridge.fallback_records[*]` contains the given
1979    /// `(from_family, to_family)` pair.
1980    SvgTextBridgeFallbackSeen {
1981        from_family: String,
1982        to_family: String,
1983    },
1984    /// True when `debug.resource_loading.asset_reload.epoch >= min`.
1985    ///
1986    /// This is intended for hot-reload / invalidation flows that want to observe the shared
1987    /// runtime-global reload epoch directly instead of inferring it indirectly from asset-load
1988    /// revision transitions.
1989    AssetReloadEpochGe {
1990        min: u64,
1991    },
1992    /// True when `debug.resource_loading.asset_reload.configured_backend == backend`.
1993    ///
1994    /// Supported values currently mirror the debug snapshot surface:
1995    /// - `poll_metadata`
1996    /// - `native_watcher`
1997    AssetReloadConfiguredBackendIs {
1998        backend: String,
1999    },
2000    /// True when `debug.resource_loading.asset_reload.active_backend == backend`.
2001    ///
2002    /// Supported values currently mirror the debug snapshot surface:
2003    /// - `poll_metadata`
2004    /// - `native_watcher`
2005    AssetReloadActiveBackendIs {
2006        backend: String,
2007    },
2008    /// True when `debug.resource_loading.asset_reload.fallback_reason == reason`.
2009    ///
2010    /// Supported values currently mirror the debug snapshot surface:
2011    /// - `watcher_install_failed`
2012    AssetReloadFallbackReasonIs {
2013        reason: String,
2014    },
2015    /// True when the runner has observed an OS accessibility activation request for the current
2016    /// window.
2017    ///
2018    /// This is intended to gate “AccessKit ↔ OS AX is actually live” rather than only asserting
2019    /// that the app has an internal semantics tree.
2020    RunnerAccessibilityActivated,
2021    VisibleInWindow {
2022        target: UiSelectorV1,
2023    },
2024    BoundsWithinWindow {
2025        target: UiSelectorV1,
2026        #[serde(default)]
2027        padding_px: f32,
2028        /// Optional per-edge padding (added on top of `padding_px`).
2029        #[serde(default, skip_serializing_if = "Option::is_none")]
2030        padding_insets_px: Option<UiPaddingInsetsV1>,
2031        #[serde(default)]
2032        eps_px: f32,
2033    },
2034    /// True when the runtime-published IME cursor area for the focused text input is fully within
2035    /// the window bounds (minus the specified padding).
2036    ///
2037    /// This is intended as a stable regression gate for keyboard-avoidance policies: after
2038    /// occlusion insets change, the focused caret/cursor area should remain inside the visible
2039    /// rect derived from safe-area + occlusion.
2040    TextInputImeCursorAreaWithinWindow {
2041        #[serde(default)]
2042        padding_px: f32,
2043        /// Optional per-edge padding (added on top of `padding_px`).
2044        #[serde(default, skip_serializing_if = "Option::is_none")]
2045        padding_insets_px: Option<UiPaddingInsetsV1>,
2046        #[serde(default)]
2047        eps_px: f32,
2048    },
2049    BoundsMinSize {
2050        target: UiSelectorV1,
2051        #[serde(default)]
2052        min_w_px: f32,
2053        #[serde(default)]
2054        min_h_px: f32,
2055        #[serde(default)]
2056        eps_px: f32,
2057    },
2058    BoundsMaxSize {
2059        target: UiSelectorV1,
2060        #[serde(default)]
2061        max_w_px: f32,
2062        #[serde(default)]
2063        max_h_px: f32,
2064        #[serde(default)]
2065        eps_px: f32,
2066    },
2067    /// True when both targets exist and their bounds match within `eps_px`.
2068    ///
2069    /// This is primarily used to gate “hit box vs visual chrome” regressions where a pressable
2070    /// can stretch but an inner chrome surface must continue to fill the same box.
2071    BoundsApproxEqual {
2072        a: UiSelectorV1,
2073        b: UiSelectorV1,
2074        #[serde(default)]
2075        eps_px: f32,
2076    },
2077    /// True when both targets exist and their bounds centers match within `eps_px`.
2078    ///
2079    /// This is primarily used to gate “stretched hit box + centered fixed chrome” contracts where
2080    /// the interactive surface can grow via flex/grid/min touch target, but the inner visual chrome
2081    /// remains fixed-size and centered.
2082    BoundsCenterApproxEqual {
2083        a: UiSelectorV1,
2084        b: UiSelectorV1,
2085        #[serde(default)]
2086        eps_px: f32,
2087    },
2088    BoundsNonOverlapping {
2089        a: UiSelectorV1,
2090        b: UiSelectorV1,
2091        #[serde(default)]
2092        eps_px: f32,
2093    },
2094    BoundsOverlapping {
2095        a: UiSelectorV1,
2096        b: UiSelectorV1,
2097        #[serde(default)]
2098        eps_px: f32,
2099    },
2100    BoundsOverlappingX {
2101        a: UiSelectorV1,
2102        b: UiSelectorV1,
2103        #[serde(default)]
2104        eps_px: f32,
2105    },
2106    BoundsOverlappingY {
2107        a: UiSelectorV1,
2108        b: UiSelectorV1,
2109        #[serde(default)]
2110        eps_px: f32,
2111    },
2112    /// True when the diagnostics event ring contains an event whose recorded kind equals `kind`.
2113    ///
2114    /// This is intentionally a coarse predicate: it is meant to gate “a platform completion was
2115    /// delivered” without requiring a dedicated predicate per event type.
2116    EventKindSeen {
2117        event_kind: String,
2118    },
2119    /// True when the app snapshot field addressed by JSON Pointer `pointer` equals `value`.
2120    ///
2121    /// This predicate reads the best-effort `app_snapshot` payload published by the app into
2122    /// diagnostics snapshots. The pointer uses RFC 6901 JSON Pointer syntax (for example:
2123    /// `/shell/settings_open` or `/shell/last_action`).
2124    ///
2125    /// If the app does not publish an `app_snapshot`, or the pointer does not resolve to a value,
2126    /// this predicate evaluates to false.
2127    AppSnapshotFieldEquals {
2128        pointer: String,
2129        value: serde_json::Value,
2130    },
2131    /// True when the diagnostics runtime has observed at least `n` windows.
2132    ///
2133    /// This is intended for multi-window scripted repros (tear-off, auxiliary windows).
2134    KnownWindowCountGe {
2135        n: u32,
2136    },
2137    /// True when the diagnostics runtime has observed exactly `n` windows.
2138    ///
2139    /// This is useful for degradation gates where creating additional windows must be prevented
2140    /// (e.g. Wayland-safe docking tear-off degradation).
2141    KnownWindowCountIs {
2142        n: u32,
2143    },
2144    /// True when the latest diagnostics snapshot includes platform capability information and it
2145    /// reports `ui.window_hover_detection == quality`.
2146    ///
2147    /// Supported qualities:
2148    /// - `none`
2149    /// - `best_effort`
2150    /// - `reliable`
2151    PlatformUiWindowHoverDetectionIs {
2152        quality: String,
2153    },
2154    /// True when the platform-level "receiver window at cursor" probe reports that the active
2155    /// cursor position would be routed to `window` (best-effort).
2156    ///
2157    /// This predicate is intended to gate runner-level hit-test passthrough behavior (e.g.
2158    /// `WM_NCHITTEST` on Win32) without relying on pixel screenshots.
2159    ///
2160    /// Capability-gated behind `diag.platform_window_receiver_at_cursor_v1`.
2161    PlatformWindowReceiverAtCursorIs {
2162        window: UiWindowTargetV1,
2163    },
2164    /// True when the effective (clamped) OS window style for `window` matches the provided facets.
2165    ///
2166    /// This predicate is capability-gated and intended for non-pixel regression gates for utility
2167    /// windows (frameless/transparent/always-on-top posture).
2168    WindowStyleEffectiveIs {
2169        window: UiWindowTargetV1,
2170        style: UiWindowStyleMatchV1,
2171    },
2172    /// True when the effective (clamped) OS window background material for `window` matches `material`.
2173    ///
2174    /// This predicate is capability-gated and intended to gate deterministic degradation paths
2175    /// when OS materials are unsupported.
2176    WindowBackgroundMaterialEffectiveIs {
2177        window: UiWindowTargetV1,
2178        material: UiWindowBackgroundMaterialRequestV1,
2179    },
2180    /// True when the latest docking diagnostics report an active dock drag whose `current_window`
2181    /// matches `window`.
2182    DockDragCurrentWindowIs {
2183        window: UiWindowTargetV1,
2184    },
2185    /// True when the latest diagnostics report an active dock drag whose drag kind matches `kind`.
2186    ///
2187    /// Supported kinds:
2188    /// - `dock_panel`
2189    /// - `dock_tabs`
2190    DockDragKindIs {
2191        drag_kind: String,
2192    },
2193    /// True when the latest docking diagnostics report an active dock drag whose runner-owned
2194    /// moving window matches `window`.
2195    ///
2196    /// This is intended for ImGui-style multi-window docking where a torn-off window follows the
2197    /// cursor while dragging.
2198    DockDragMovingWindowIs {
2199        window: UiWindowTargetV1,
2200    },
2201    /// True when the latest docking diagnostics report an active dock drag whose
2202    /// "window under moving window" matches `window`.
2203    ///
2204    /// This allows scripts to gate "peek-behind" selection paths without reinterpreting
2205    /// `dock_drag_current_window_is` (which remains the runner's primary hover/drop routing
2206    /// target).
2207    DockDragWindowUnderMovingWindowIs {
2208        window: UiWindowTargetV1,
2209    },
2210    /// True when the latest docking diagnostics report an active dock drag session.
2211    DockDragActiveIs {
2212        active: bool,
2213    },
2214    /// True when the latest docking diagnostics report that the shell-local dock payload ghost is
2215    /// visible in the evaluated window.
2216    ///
2217    /// This is intended to gate shell choreography: once a real `moving_window` takes ownership
2218    /// of drag feedback, the in-window payload ghost should no longer paint.
2219    DockDragPayloadGhostVisibleIs {
2220        visible: bool,
2221    },
2222    /// True when the latest docking diagnostics report a dock drag session with an ImGui-style
2223    /// "transparent payload" applied to the moving window (e.g. reduced opacity and/or
2224    /// click-through hit-test passthrough while the dock-floating window follows the cursor).
2225    DockDragTransparentPayloadAppliedIs {
2226        applied: bool,
2227    },
2228    /// True when the latest docking diagnostics report that the runner successfully applied
2229    /// click-through hit-test passthrough for the moving window during transparent payload.
2230    DockDragTransparentPayloadHitTestPassthroughAppliedIs {
2231        applied: bool,
2232    },
2233    /// True when the latest docking diagnostics report a dock drag session whose hovered-window
2234    /// selection source matches `source`.
2235    ///
2236    /// This is primarily intended to gate multi-window docking hand-feel regressions: on
2237    /// platforms that claim `ui.window_hover_detection=reliable`, we want to ensure the runner is
2238    /// using an OS-backed "window under cursor" provider rather than a heuristic fallback.
2239    ///
2240    /// Supported sources:
2241    /// - `platform`: any OS-backed platform hover provider
2242    /// - `platform_win32`
2243    /// - `platform_macos`
2244    /// - `latched`
2245    /// - `heuristic`: any heuristic fallback
2246    /// - `heuristic_z_order`
2247    /// - `heuristic_rects`
2248    /// - `unknown`
2249    DockDragWindowUnderCursorSourceIs {
2250        source: String,
2251    },
2252    /// True when the latest docking diagnostics report a dock drag session whose
2253    /// "window under moving window" selection source matches `source`.
2254    ///
2255    /// Supported sources:
2256    /// - `platform`: any OS-backed platform hover provider
2257    /// - `platform_win32`
2258    /// - `platform_macos`
2259    /// - `latched`
2260    /// - `heuristic`: any heuristic fallback
2261    /// - `heuristic_z_order`
2262    /// - `heuristic_rects`
2263    /// - `unknown`
2264    DockDragWindowUnderMovingWindowSourceIs {
2265        source: String,
2266    },
2267    /// True when the latest docking diagnostics report an active in-window floating drag session.
2268    ///
2269    /// This is intended to gate "floating window" hand-feel regressions without relying on pixels.
2270    DockFloatingDragActiveIs {
2271        active: bool,
2272    },
2273    /// True when the current docking drop preview kind matches `kind`.
2274    ///
2275    /// This predicate reads the window-local `DockDropResolveDiagnostics` snapshot published into
2276    /// `WindowInteractionDiagnosticsStore` by policy-heavy ecosystem crates (e.g. docking).
2277    ///
2278    /// Supported kinds:
2279    /// - `wrap_binary`
2280    /// - `insert_into_split`
2281    DockDropPreviewKindIs {
2282        preview_kind: String,
2283    },
2284    /// True when the current docking drop resolve source matches `source`.
2285    ///
2286    /// This predicate reads the window-local `DockDropResolveDiagnostics` snapshot published into
2287    /// `WindowInteractionDiagnosticsStore` by policy-heavy ecosystem crates (e.g. docking).
2288    ///
2289    /// Supported sources:
2290    /// - `invert_docking`
2291    /// - `outside_window`
2292    /// - `float_zone`
2293    /// - `layout_bounds_miss`
2294    /// - `latched_previous_hover`
2295    /// - `tab_bar`
2296    /// - `floating_title_bar`
2297    /// - `outer_hint_rect`
2298    /// - `inner_hint_rect`
2299    /// - `none`
2300    DockDropResolveSourceIs {
2301        source: String,
2302    },
2303    /// True when the current docking drop resolve has (or does not have) a resolved target.
2304    ///
2305    /// This is useful for policy-gated no-drop zones: scripts can assert that the pointer is over
2306    /// a *candidate* region (via `dock_drop_resolve_source_is`) while `resolved` stays `None`.
2307    DockDropResolvedIsSome {
2308        some: bool,
2309    },
2310    /// True when the current docking drop resolve has a resolved target whose `zone` matches
2311    /// `zone`.
2312    ///
2313    /// Supported zones:
2314    /// - `center`
2315    /// - `left`
2316    /// - `right`
2317    /// - `top`
2318    /// - `bottom`
2319    DockDropResolvedZoneIs {
2320        zone: String,
2321    },
2322    /// True when the current docking drop resolve has a resolved target whose `insert_index`
2323    /// matches `index`.
2324    ///
2325    /// This is intended to gate "drop at end" semantics (e.g. `index == tab_count`) without
2326    /// relying on pixels.
2327    DockDropResolvedInsertIndexIs {
2328        index: u32,
2329    },
2330    /// True when the latest docking diagnostics report whether the active tab strip is overflowed.
2331    ///
2332    /// This predicate reads the best-effort `tab_strip_active_visibility` snapshot recorded by
2333    /// docking into `WindowInteractionDiagnosticsStore`.
2334    DockTabStripActiveOverflowIs {
2335        overflow: bool,
2336    },
2337    /// True when the latest docking diagnostics report whether the active tab is visible at the
2338    /// current tab scroll position.
2339    ///
2340    /// This predicate is intended to gate the editor-grade invariant:
2341    /// "selecting a tab (including via overflow menu) must scroll it into view".
2342    DockTabStripActiveVisibleIs {
2343        visible: bool,
2344    },
2345    /// True when the latest docking diagnostics report `tab_strip_active_visibility.scroll >= px`.
2346    ///
2347    /// This predicate is intended to gate edge auto-scroll during tab drags in overflowed tab
2348    /// strips, without relying on pixels.
2349    DockTabStripActiveScrollPxGe {
2350        px: f32,
2351    },
2352    /// True when the latest docking diagnostics report `tab_strip_active_visibility.scroll <= px`.
2353    ///
2354    /// This is primarily intended to assert the initial scroll state in scripted regressions.
2355    DockTabStripActiveScrollPxLe {
2356        px: f32,
2357    },
2358    /// True when the latest workspace diagnostics report whether the active tab strip is overflowed.
2359    ///
2360    /// This predicate reads the best-effort `workspace_interaction.tab_strip_active_visibility`
2361    /// snapshot recorded into `WindowInteractionDiagnosticsStore`.
2362    WorkspaceTabStripActiveOverflowIs {
2363        overflow: bool,
2364        #[serde(default, skip_serializing_if = "Option::is_none")]
2365        pane_id: Option<String>,
2366    },
2367    /// True when the latest workspace diagnostics report whether the active tab is visible at the
2368    /// current tab scroll position.
2369    ///
2370    /// This predicate is intended to gate the editor-grade invariant:
2371    /// "selecting a tab (including via overflow menu) must scroll it into view".
2372    WorkspaceTabStripActiveVisibleIs {
2373        visible: bool,
2374        #[serde(default, skip_serializing_if = "Option::is_none")]
2375        pane_id: Option<String>,
2376    },
2377    /// True when the latest workspace diagnostics report `tab_strip_active_visibility.scroll_x >= px`.
2378    ///
2379    /// This predicate reads the best-effort `workspace_interaction.tab_strip_active_visibility`
2380    /// snapshot recorded into `WindowInteractionDiagnosticsStore`.
2381    WorkspaceTabStripActiveScrollPxGe {
2382        px: f32,
2383        #[serde(default, skip_serializing_if = "Option::is_none")]
2384        pane_id: Option<String>,
2385    },
2386    /// True when the latest workspace diagnostics report `tab_strip_active_visibility.scroll_x <= px`.
2387    ///
2388    /// This predicate reads the best-effort `workspace_interaction.tab_strip_active_visibility`
2389    /// snapshot recorded into `WindowInteractionDiagnosticsStore`.
2390    WorkspaceTabStripActiveScrollPxLe {
2391        px: f32,
2392        #[serde(default, skip_serializing_if = "Option::is_none")]
2393        pane_id: Option<String>,
2394    },
2395    /// True when the latest workspace diagnostics report an active tab strip drag session.
2396    ///
2397    /// This predicate reads the best-effort `workspace_interaction.tab_strip_drag` snapshot
2398    /// recorded into `WindowInteractionDiagnosticsStore`.
2399    WorkspaceTabStripDragActiveIs {
2400        active: bool,
2401        #[serde(default, skip_serializing_if = "Option::is_none")]
2402        pane_id: Option<String>,
2403    },
2404    /// True when the latest workspace diagnostics report whether a tab strip drag session is armed
2405    /// (i.e. tracking a pointer that may become a drag on move threshold).
2406    ///
2407    /// This predicate reads the best-effort `workspace_interaction.tab_strip_drag` snapshot
2408    /// recorded into `WindowInteractionDiagnosticsStore`.
2409    WorkspaceTabStripDragArmedIs {
2410        armed: bool,
2411        #[serde(default, skip_serializing_if = "Option::is_none")]
2412        pane_id: Option<String>,
2413    },
2414    /// True when the latest dock graph stats snapshot reports a canonical-form layout.
2415    DockGraphCanonicalIs {
2416        canonical: bool,
2417    },
2418    /// True when the latest dock graph stats snapshot reports nested same-axis split children.
2419    DockGraphHasNestedSameAxisSplitsIs {
2420        has_nested: bool,
2421    },
2422    /// True when the latest dock graph stats snapshot reports `node_count <= max`.
2423    ///
2424    /// This is intended for scripted regression gates that want to ensure repeated dock operations
2425    /// do not accidentally allocate unbounded structure (e.g. legacy "wrap" behavior that deepens
2426    /// the split tree).
2427    DockGraphNodeCountLe {
2428        max: u32,
2429    },
2430    /// True when the latest dock graph stats snapshot reports `max_split_depth <= max`.
2431    DockGraphMaxSplitDepthLe {
2432        max: u32,
2433    },
2434    /// True when the latest dock graph signature snapshot matches `signature`.
2435    ///
2436    /// This signature is intended to be stable across runs and platforms:
2437    /// - it does not include split fractions (pointer-driven and DPI-sensitive),
2438    /// - it does not include floating window rects (platform-dependent).
2439    DockGraphSignatureIs {
2440        signature: String,
2441    },
2442    /// True when the latest dock graph signature snapshot contains `needle` as a substring.
2443    ///
2444    /// This is useful for large layouts where asserting the entire signature string would be too
2445    /// verbose.
2446    DockGraphSignatureContains {
2447        needle: String,
2448    },
2449    /// True when the latest dock graph signature snapshot does **not** contain `needle` as a
2450    /// substring.
2451    DockGraphSignatureNotContains {
2452        needle: String,
2453    },
2454    /// True when the latest dock graph signature fingerprint matches `fingerprint64`.
2455    DockGraphSignatureFingerprint64Is {
2456        fingerprint64: u64,
2457    },
2458}
2459
2460#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2461#[serde(rename_all = "snake_case")]
2462pub enum UiSemanticsNumericFieldV1 {
2463    Value,
2464    Min,
2465    Max,
2466    Step,
2467    Jump,
2468}
2469
2470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2471#[serde(rename_all = "snake_case")]
2472pub enum UiSemanticsScrollFieldV1 {
2473    X,
2474    XMin,
2475    XMax,
2476    Y,
2477    YMin,
2478    YMax,
2479}
2480
2481#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
2482#[serde(rename_all = "snake_case")]
2483pub enum UiOptionalRootStateV1 {
2484    #[default]
2485    Any,
2486    None,
2487    Some,
2488}
2489
2490#[derive(Debug, Clone, Serialize, Deserialize)]
2491#[serde(tag = "kind", rename_all = "snake_case")]
2492pub enum UiSelectorV1 {
2493    RoleAndName {
2494        role: String,
2495        name: String,
2496        #[serde(default, skip_serializing_if = "Option::is_none")]
2497        root_z_index: Option<u32>,
2498    },
2499    RoleAndPath {
2500        role: String,
2501        name: String,
2502        ancestors: Vec<UiRoleAndNameV1>,
2503        #[serde(default, skip_serializing_if = "Option::is_none")]
2504        root_z_index: Option<u32>,
2505    },
2506    TestId {
2507        id: String,
2508        #[serde(default, skip_serializing_if = "Option::is_none")]
2509        root_z_index: Option<u32>,
2510    },
2511    GlobalElementId {
2512        element: u64,
2513        #[serde(default, skip_serializing_if = "Option::is_none")]
2514        root_z_index: Option<u32>,
2515    },
2516    NodeId {
2517        node: u64,
2518        #[serde(default, skip_serializing_if = "Option::is_none")]
2519        root_z_index: Option<u32>,
2520    },
2521}
2522
2523#[derive(Debug, Clone, Serialize, Deserialize)]
2524pub struct UiRoleAndNameV1 {
2525    pub role: String,
2526    pub name: String,
2527}
2528
2529#[derive(Debug, Clone, Serialize, Deserialize)]
2530pub struct UiSemanticsNodeGetV1 {
2531    pub schema_version: u32,
2532    pub window: u64,
2533    pub node_id: u64,
2534}
2535
2536#[derive(Debug, Clone, Serialize, Deserialize)]
2537pub struct UiSemanticsNodeGetAckV1 {
2538    pub schema_version: u32,
2539    pub status: String,
2540    #[serde(default, skip_serializing_if = "Option::is_none")]
2541    pub reason: Option<String>,
2542    pub window: u64,
2543    pub node_id: u64,
2544    #[serde(default, skip_serializing_if = "Option::is_none")]
2545    pub semantics_fingerprint: Option<u64>,
2546    #[serde(default, skip_serializing_if = "Option::is_none")]
2547    pub node: Option<serde_json::Value>,
2548    #[serde(default)]
2549    pub children: Vec<u64>,
2550    #[serde(default, skip_serializing_if = "Option::is_none")]
2551    pub captured_unix_ms: Option<u64>,
2552}
2553
2554#[derive(Debug, Clone, Serialize, Deserialize)]
2555pub struct UiHitTestExplainV1 {
2556    pub schema_version: u32,
2557    pub window: u64,
2558    pub target: UiSelectorV1,
2559}
2560
2561#[derive(Debug, Clone, Serialize, Deserialize)]
2562pub struct UiHitTestExplainAckV1 {
2563    pub schema_version: u32,
2564    pub status: String,
2565    #[serde(default, skip_serializing_if = "Option::is_none")]
2566    pub reason: Option<String>,
2567    pub window: u64,
2568    pub target: UiSelectorV1,
2569    #[serde(default, skip_serializing_if = "Option::is_none")]
2570    pub semantics_fingerprint: Option<u64>,
2571    #[serde(default, skip_serializing_if = "Option::is_none")]
2572    pub hittable: Option<bool>,
2573    #[serde(default, skip_serializing_if = "Option::is_none")]
2574    pub hit_test: Option<UiHitTestTraceEntryV1>,
2575    #[serde(default, skip_serializing_if = "Option::is_none")]
2576    pub captured_unix_ms: Option<u64>,
2577}
2578
2579#[derive(Debug, Clone, Serialize, Deserialize)]
2580pub struct UiInspectConfigV1 {
2581    pub schema_version: u32,
2582    pub enabled: bool,
2583    #[serde(default = "serde_default_true")]
2584    pub consume_clicks: bool,
2585}
2586
2587#[derive(Debug, Clone, Serialize, Deserialize)]
2588pub struct DevtoolsBundleDumpV1 {
2589    pub schema_version: u32,
2590    #[serde(default, skip_serializing_if = "Option::is_none")]
2591    pub label: Option<String>,
2592    /// Optional per-dump cap on how many snapshots are included in the exported bundle.
2593    ///
2594    /// When omitted, the runtime uses its configured dump cap (typically
2595    /// `FRET_DIAG_SCRIPT_DUMP_MAX_SNAPSHOTS` for script-driven dumps, and
2596    /// `FRET_DIAG_MAX_SNAPSHOTS` for manual dumps).
2597    #[serde(default, skip_serializing_if = "Option::is_none")]
2598    pub max_snapshots: Option<u32>,
2599}
2600
2601/// Request that the app exits as soon as possible.
2602///
2603/// This is intended for transport-neutral "exit after run" behavior in CI / scripted automation
2604/// flows where relying on large timeouts is undesirable.
2605#[derive(Debug, Clone, Serialize, Deserialize)]
2606pub struct DevtoolsAppExitRequestV1 {
2607    pub schema_version: u32,
2608    #[serde(default, skip_serializing_if = "Option::is_none")]
2609    pub reason: Option<String>,
2610    /// Optional delay before triggering exit, expressed in wall-clock milliseconds.
2611    #[serde(default, skip_serializing_if = "Option::is_none")]
2612    pub delay_ms: Option<u64>,
2613}
2614
2615#[derive(Debug, Clone, Serialize, Deserialize)]
2616pub struct DevtoolsBundleDumpedV1 {
2617    pub schema_version: u32,
2618    pub exported_unix_ms: u64,
2619    pub out_dir: String,
2620    pub dir: String,
2621    #[serde(default, skip_serializing_if = "Option::is_none")]
2622    pub bundle: Option<serde_json::Value>,
2623    /// Optional chunked representation of the embedded bundle JSON.
2624    ///
2625    /// When present, the runtime may send multiple `bundle.dumped` messages (same `exported_unix_ms`
2626    /// + `dir`) each carrying one chunk. Tooling should reassemble chunks in order to reconstruct
2627    ///   the full JSON payload.
2628    #[serde(default, skip_serializing_if = "Option::is_none")]
2629    pub bundle_json_chunk: Option<String>,
2630    #[serde(default, skip_serializing_if = "Option::is_none")]
2631    pub bundle_json_chunk_index: Option<u32>,
2632    #[serde(default, skip_serializing_if = "Option::is_none")]
2633    pub bundle_json_chunk_count: Option<u32>,
2634}
2635
2636#[derive(Debug, Clone, Serialize, Deserialize)]
2637pub struct DevtoolsScreenshotRequestV1 {
2638    pub schema_version: u32,
2639    #[serde(default, skip_serializing_if = "Option::is_none")]
2640    pub label: Option<String>,
2641    #[serde(default = "default_capture_screenshot_timeout_frames")]
2642    pub timeout_frames: u32,
2643    #[serde(default, skip_serializing_if = "Option::is_none")]
2644    pub window: Option<u64>,
2645}
2646
2647#[derive(Debug, Clone, Serialize, Deserialize)]
2648pub struct DevtoolsScreenshotResultV1 {
2649    pub schema_version: u32,
2650    pub status: String,
2651    #[serde(default, skip_serializing_if = "Option::is_none")]
2652    pub reason: Option<String>,
2653    pub request_id: String,
2654    pub window: u64,
2655    pub bundle_dir_name: String,
2656    #[serde(default, skip_serializing_if = "Option::is_none")]
2657    pub screenshots_dir: Option<String>,
2658    #[serde(default, skip_serializing_if = "Option::is_none")]
2659    pub entry: Option<serde_json::Value>,
2660}
2661
2662/// GPU screenshot request written by the in-app diagnostics runtime, consumed by desktop runners.
2663///
2664/// This is the transport between:
2665///
2666/// - `ecosystem/fret-bootstrap` (writer; script steps + DevTools WS bridge), and
2667/// - `crates/fret-launch` (reader; runner-owned GPU readback + PNG encoding).
2668///
2669/// Keeping this schema in `fret-diag-protocol` avoids "forked" JSON parsing logic across crates.
2670#[derive(Debug, Clone, Serialize, Deserialize)]
2671pub struct DiagScreenshotRequestV1 {
2672    pub schema_version: u32,
2673    pub out_dir: String,
2674    pub bundle_dir_name: String,
2675    #[serde(default, skip_serializing_if = "Option::is_none")]
2676    pub request_id: Option<String>,
2677    #[serde(default)]
2678    pub windows: Vec<DiagScreenshotWindowRequestV1>,
2679}
2680
2681#[derive(Debug, Clone, Serialize, Deserialize)]
2682pub struct DiagScreenshotWindowRequestV1 {
2683    pub window: u64,
2684    pub tick_id: u64,
2685    pub frame_id: u64,
2686    #[serde(default = "serde_default_one_f64")]
2687    pub scale_factor: f64,
2688}
2689
2690#[derive(Debug, Clone, Serialize, Deserialize)]
2691pub struct DiagScreenshotResultFileV1 {
2692    #[serde(default = "default_diag_screenshot_schema_version")]
2693    pub schema_version: u32,
2694    #[serde(default, skip_serializing_if = "Option::is_none")]
2695    pub updated_unix_ms: Option<u64>,
2696    #[serde(default)]
2697    pub completed: Vec<DiagScreenshotResultEntryV1>,
2698}
2699
2700impl Default for DiagScreenshotResultFileV1 {
2701    fn default() -> Self {
2702        Self {
2703            schema_version: default_diag_screenshot_schema_version(),
2704            updated_unix_ms: None,
2705            completed: Vec::new(),
2706        }
2707    }
2708}
2709
2710#[derive(Debug, Clone, Serialize, Deserialize)]
2711pub struct DiagScreenshotResultEntryV1 {
2712    #[serde(default, skip_serializing_if = "Option::is_none")]
2713    pub request_id: Option<String>,
2714    pub bundle_dir_name: String,
2715    pub window: u64,
2716    pub tick_id: u64,
2717    pub frame_id: u64,
2718    pub scale_factor: f32,
2719    pub file: String,
2720    pub width_px: u32,
2721    pub height_px: u32,
2722    pub completed_unix_ms: u64,
2723}
2724
2725#[derive(Debug, Clone, Serialize, Deserialize)]
2726pub struct UiArtifactStatsV1 {
2727    pub schema_version: u32,
2728    #[serde(default, skip_serializing_if = "Option::is_none")]
2729    pub bundle_json_bytes: Option<u64>,
2730    #[serde(default)]
2731    pub window_count: u64,
2732    #[serde(default)]
2733    pub event_count: u64,
2734    #[serde(default)]
2735    pub snapshot_count: u64,
2736    #[serde(default)]
2737    pub max_snapshots: u64,
2738    #[serde(default, skip_serializing_if = "Option::is_none")]
2739    pub dump_max_snapshots: Option<u64>,
2740}
2741
2742#[derive(Debug, Clone, Serialize, Deserialize)]
2743pub struct UiScriptResultV1 {
2744    pub schema_version: u32,
2745    pub run_id: u64,
2746    pub updated_unix_ms: u64,
2747    pub window: Option<u64>,
2748    pub stage: UiScriptStageV1,
2749    pub step_index: Option<u32>,
2750    #[serde(default, skip_serializing_if = "Option::is_none")]
2751    pub reason_code: Option<String>,
2752    pub reason: Option<String>,
2753    #[serde(default, skip_serializing_if = "Option::is_none")]
2754    pub evidence: Option<UiScriptEvidenceV1>,
2755    pub last_bundle_dir: Option<String>,
2756    #[serde(default, skip_serializing_if = "Option::is_none")]
2757    pub last_bundle_artifact: Option<UiArtifactStatsV1>,
2758}
2759
2760#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2761pub struct UiScriptEvidenceV1 {
2762    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2763    pub event_log: Vec<UiScriptEventLogEntryV1>,
2764    #[serde(default, skip_serializing_if = "is_zero_u64")]
2765    pub event_log_dropped: u64,
2766    #[serde(default, skip_serializing_if = "Option::is_none")]
2767    pub capabilities_check: Option<UiCapabilitiesCheckV1>,
2768    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2769    pub selector_resolution_trace: Vec<UiSelectorResolutionTraceEntryV1>,
2770    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2771    pub hit_test_trace: Vec<UiHitTestTraceEntryV1>,
2772    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2773    pub click_stable_trace: Vec<UiClickStableTraceEntryV1>,
2774    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2775    pub bounds_stable_trace: Vec<UiBoundsStableTraceEntryV1>,
2776    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2777    pub focus_trace: Vec<UiFocusTraceEntryV1>,
2778    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2779    pub shortcut_routing_trace: Vec<UiShortcutRoutingTraceEntryV1>,
2780    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2781    pub command_dispatch_trace: Vec<UiCommandDispatchTraceEntryV1>,
2782    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2783    pub overlay_placement_trace: Vec<UiOverlayPlacementTraceEntryV1>,
2784    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2785    pub web_ime_trace: Vec<UiWebImeTraceEntryV1>,
2786    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2787    pub ime_event_trace: Vec<UiImeEventTraceEntryV1>,
2788}
2789
2790#[derive(Debug, Clone, Serialize, Deserialize)]
2791pub struct UiCapabilitiesCheckV1 {
2792    pub schema_version: u32,
2793    pub source: String,
2794    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2795    pub required: Vec<String>,
2796    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2797    pub available: Vec<String>,
2798    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2799    pub missing: Vec<String>,
2800}
2801
2802#[derive(Debug, Clone, Serialize, Deserialize)]
2803pub struct UiScriptEventLogEntryV1 {
2804    pub unix_ms: u64,
2805    pub kind: String,
2806    #[serde(default, skip_serializing_if = "Option::is_none")]
2807    pub step_index: Option<u32>,
2808    #[serde(default, skip_serializing_if = "Option::is_none")]
2809    pub note: Option<String>,
2810    #[serde(default, skip_serializing_if = "Option::is_none")]
2811    pub bundle_dir: Option<String>,
2812    /// When available, identifies the window that observed/emitted this event.
2813    #[serde(default, skip_serializing_if = "Option::is_none")]
2814    pub window: Option<u64>,
2815    /// When available, the app tick id at the time of the event.
2816    #[serde(default, skip_serializing_if = "Option::is_none")]
2817    pub tick_id: Option<u64>,
2818    /// When available, the app frame id at the time of the event.
2819    #[serde(default, skip_serializing_if = "Option::is_none")]
2820    pub frame_id: Option<u64>,
2821    /// Optional per-window snapshot sequence hint (may be resolved by tooling from `bundle.index.json`).
2822    #[serde(default, skip_serializing_if = "Option::is_none")]
2823    pub window_snapshot_seq: Option<u64>,
2824}
2825
2826#[derive(Debug, Clone, Serialize, Deserialize)]
2827pub struct UiSelectorResolutionTraceEntryV1 {
2828    pub step_index: u32,
2829    pub selector: UiSelectorV1,
2830    #[serde(default)]
2831    pub match_count: u32,
2832    #[serde(default, skip_serializing_if = "Option::is_none")]
2833    pub chosen_node_id: Option<u64>,
2834    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2835    pub candidates: Vec<UiSelectorResolutionCandidateV1>,
2836    #[serde(default, skip_serializing_if = "Option::is_none")]
2837    pub note: Option<String>,
2838}
2839
2840#[derive(Debug, Clone, Serialize, Deserialize)]
2841pub struct UiSelectorResolutionCandidateV1 {
2842    pub node_id: u64,
2843    pub role: String,
2844    #[serde(default, skip_serializing_if = "Option::is_none")]
2845    pub name: Option<String>,
2846    #[serde(default, skip_serializing_if = "Option::is_none")]
2847    pub test_id: Option<String>,
2848}
2849
2850#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2851pub struct UiPointV1 {
2852    pub x_px: f32,
2853    pub y_px: f32,
2854}
2855
2856#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2857pub struct UiRectV1 {
2858    pub x_px: f32,
2859    pub y_px: f32,
2860    pub w_px: f32,
2861    pub h_px: f32,
2862}
2863
2864#[derive(Debug, Clone, Serialize, Deserialize)]
2865pub struct UiHitTestTraceEntryV1 {
2866    pub step_index: u32,
2867    pub selector: UiSelectorV1,
2868    pub position: UiPointV1,
2869    #[serde(default, skip_serializing_if = "Option::is_none")]
2870    pub intended_node_id: Option<u64>,
2871    #[serde(default, skip_serializing_if = "Option::is_none")]
2872    pub intended_test_id: Option<String>,
2873    #[serde(default, skip_serializing_if = "Option::is_none")]
2874    pub intended_bounds: Option<UiRectV1>,
2875    #[serde(default, skip_serializing_if = "Option::is_none")]
2876    pub hit_node_id: Option<u64>,
2877    /// Debug-only path from the root to `hit_node_id` (inclusive).
2878    ///
2879    /// Treat node ids as in-run references only; they are not stable across runs.
2880    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2881    pub hit_node_path: Vec<u64>,
2882    #[serde(default, skip_serializing_if = "Option::is_none")]
2883    pub hit_semantics_node_id: Option<u64>,
2884    #[serde(default, skip_serializing_if = "Option::is_none")]
2885    pub hit_semantics_test_id: Option<String>,
2886    #[serde(default, skip_serializing_if = "Option::is_none")]
2887    pub includes_intended: Option<bool>,
2888    /// Best-effort: whether the hit-test path contains the intended node id.
2889    ///
2890    /// Useful for diagnosing “clicked the right region but an overlay/capture blocked delivery”.
2891    #[serde(default, skip_serializing_if = "Option::is_none")]
2892    pub hit_path_contains_intended: Option<bool>,
2893    /// Best-effort attribution for why the intended target did not receive injected input.
2894    ///
2895    /// This is a convenience field intended for triage tools and AI. Prefer inspecting the raw
2896    /// evidence fields when debugging novel cases.
2897    ///
2898    /// Stable strings (start small; expand only when evidence becomes more actionable):
2899    /// - `modal_barrier` (a modal barrier is active)
2900    /// - `focus_barrier` (a focus barrier is active)
2901    /// - `pointer_capture` (pointer capture is active)
2902    /// - `pointer_occlusion` (pointer occlusion blocks underlay input)
2903    /// - `no_hit` (hit-test produced no node)
2904    /// - `miss` (hit-test landed on a different node)
2905    #[serde(default, skip_serializing_if = "Option::is_none")]
2906    pub blocking_reason: Option<String>,
2907    /// Best-effort in-run root reference associated with `blocking_reason` (when applicable).
2908    #[serde(default, skip_serializing_if = "Option::is_none")]
2909    pub blocking_root: Option<u64>,
2910    /// Best-effort layer id associated with `blocking_reason` (when applicable).
2911    #[serde(default, skip_serializing_if = "Option::is_none")]
2912    pub blocking_layer_id: Option<u64>,
2913    /// Best-effort human-readable explanation for `blocking_reason`.
2914    ///
2915    /// This is intended for fast triage and AI; treat it as a hint rather than a contract.
2916    #[serde(default, skip_serializing_if = "Option::is_none")]
2917    pub routing_explain: Option<String>,
2918    #[serde(default, skip_serializing_if = "Option::is_none")]
2919    pub barrier_root: Option<u64>,
2920    #[serde(default, skip_serializing_if = "Option::is_none")]
2921    pub focus_barrier_root: Option<u64>,
2922    /// The input arbitration snapshot at the time this trace entry was recorded.
2923    ///
2924    /// These fields are primarily useful for explaining why injected input did not reach the
2925    /// underlay (pointer occlusion/capture/focus barriers).
2926    #[serde(default, skip_serializing_if = "Option::is_none")]
2927    pub pointer_occlusion: Option<String>,
2928    #[serde(default, skip_serializing_if = "Option::is_none")]
2929    pub pointer_occlusion_layer_id: Option<u64>,
2930    /// Best-effort pointer occlusion owner (in-run references only).
2931    ///
2932    /// When `pointer_occlusion_layer_id` is present, these fields attempt to resolve the layer
2933    /// root to a semantics node for easier triage.
2934    #[serde(default, skip_serializing_if = "Option::is_none")]
2935    pub pointer_occlusion_node_id: Option<u64>,
2936    #[serde(default, skip_serializing_if = "Option::is_none")]
2937    pub pointer_occlusion_test_id: Option<String>,
2938    #[serde(default, skip_serializing_if = "Option::is_none")]
2939    pub pointer_occlusion_role: Option<String>,
2940    #[serde(default, skip_serializing_if = "Option::is_none")]
2941    pub pointer_occlusion_bounds: Option<UiRectV1>,
2942    #[serde(default, skip_serializing_if = "Option::is_none")]
2943    pub pointer_capture_active: Option<bool>,
2944    #[serde(default, skip_serializing_if = "Option::is_none")]
2945    pub pointer_capture_layer_id: Option<u64>,
2946    #[serde(default, skip_serializing_if = "Option::is_none")]
2947    pub pointer_capture_multiple_layers: Option<bool>,
2948    /// Best-effort pointer capture owner (in-run references only).
2949    #[serde(default, skip_serializing_if = "Option::is_none")]
2950    pub pointer_capture_node_id: Option<u64>,
2951    #[serde(default, skip_serializing_if = "Option::is_none")]
2952    pub pointer_capture_test_id: Option<String>,
2953    #[serde(default, skip_serializing_if = "Option::is_none")]
2954    pub pointer_capture_role: Option<String>,
2955    #[serde(default, skip_serializing_if = "Option::is_none")]
2956    pub pointer_capture_bounds: Option<UiRectV1>,
2957    #[serde(default, skip_serializing_if = "Option::is_none")]
2958    pub pointer_capture_element: Option<u64>,
2959    #[serde(default, skip_serializing_if = "Option::is_none")]
2960    pub pointer_capture_element_path: Option<String>,
2961    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2962    pub scope_roots: Vec<UiHitTestScopeRootEvidenceV1>,
2963    #[serde(default, skip_serializing_if = "Option::is_none")]
2964    pub note: Option<String>,
2965}
2966
2967#[derive(Debug, Clone, Serialize, Deserialize)]
2968pub struct UiClickStableTraceEntryV1 {
2969    pub step_index: u32,
2970    pub stable_required: u32,
2971    pub stable_count: u32,
2972    pub moved_px: f32,
2973    pub max_move_px: f32,
2974    pub remaining_frames: u32,
2975    pub hit_test: UiHitTestTraceEntryV1,
2976}
2977
2978#[derive(Debug, Clone, Serialize, Deserialize)]
2979pub struct UiBoundsStableTraceEntryV1 {
2980    pub step_index: u32,
2981    pub selector: UiSelectorV1,
2982    pub stable_required: u32,
2983    pub stable_count: u32,
2984    pub moved_px: f32,
2985    pub max_move_px: f32,
2986    pub remaining_frames: u32,
2987    #[serde(default, skip_serializing_if = "Option::is_none")]
2988    pub bounds: Option<UiRectV1>,
2989    #[serde(default, skip_serializing_if = "Option::is_none")]
2990    pub note: Option<String>,
2991}
2992
2993#[derive(Debug, Clone, Serialize, Deserialize)]
2994pub struct UiHitTestScopeRootEvidenceV1 {
2995    pub kind: String,
2996    pub root: u64,
2997    #[serde(default, skip_serializing_if = "Option::is_none")]
2998    pub layer_id: Option<u64>,
2999    #[serde(default, skip_serializing_if = "Option::is_none")]
3000    pub pointer_occlusion: Option<String>,
3001    #[serde(default, skip_serializing_if = "Option::is_none")]
3002    pub blocks_underlay_input: Option<bool>,
3003    #[serde(default, skip_serializing_if = "Option::is_none")]
3004    pub hit_testable: Option<bool>,
3005}
3006
3007#[derive(Debug, Clone, Serialize, Deserialize)]
3008pub struct UiFocusTraceEntryV1 {
3009    pub step_index: u32,
3010    #[serde(default, skip_serializing_if = "Option::is_none")]
3011    pub note: Option<String>,
3012    #[serde(default, skip_serializing_if = "Option::is_none")]
3013    pub reason_code: Option<String>,
3014    #[serde(default, skip_serializing_if = "Option::is_none")]
3015    pub text_input_snapshot: Option<UiTextInputSnapshotV1>,
3016    #[serde(default, skip_serializing_if = "Option::is_none")]
3017    pub expected_node_id: Option<u64>,
3018    #[serde(default, skip_serializing_if = "Option::is_none")]
3019    pub expected_test_id: Option<String>,
3020    #[serde(default, skip_serializing_if = "Option::is_none")]
3021    pub modal_barrier_root: Option<u64>,
3022    #[serde(default, skip_serializing_if = "Option::is_none")]
3023    pub focus_barrier_root: Option<u64>,
3024    #[serde(default, skip_serializing_if = "Option::is_none")]
3025    pub pointer_occlusion: Option<String>,
3026    #[serde(default, skip_serializing_if = "Option::is_none")]
3027    pub pointer_occlusion_layer_id: Option<u64>,
3028    #[serde(default, skip_serializing_if = "Option::is_none")]
3029    pub pointer_capture_active: Option<bool>,
3030    #[serde(default, skip_serializing_if = "Option::is_none")]
3031    pub pointer_capture_layer_id: Option<u64>,
3032    #[serde(default, skip_serializing_if = "Option::is_none")]
3033    pub pointer_capture_multiple_layers: Option<bool>,
3034    #[serde(default, skip_serializing_if = "Option::is_none")]
3035    pub focused_element: Option<u64>,
3036    #[serde(default, skip_serializing_if = "Option::is_none")]
3037    pub focused_element_path: Option<String>,
3038    #[serde(default, skip_serializing_if = "Option::is_none")]
3039    pub focused_node_id: Option<u64>,
3040    #[serde(default, skip_serializing_if = "Option::is_none")]
3041    pub focused_test_id: Option<String>,
3042    #[serde(default, skip_serializing_if = "Option::is_none")]
3043    pub focused_role: Option<String>,
3044    #[serde(default, skip_serializing_if = "Option::is_none")]
3045    pub matches_expected: Option<bool>,
3046}
3047
3048#[derive(Debug, Clone, Serialize, Deserialize)]
3049pub struct UiTextInputSnapshotV1 {
3050    #[serde(default)]
3051    pub focus_is_text_input: bool,
3052    #[serde(default)]
3053    pub is_composing: bool,
3054    #[serde(default)]
3055    pub text_len_utf16: u32,
3056    #[serde(default, skip_serializing_if = "Option::is_none")]
3057    pub selection_utf16: Option<(u32, u32)>,
3058    #[serde(default, skip_serializing_if = "Option::is_none")]
3059    pub marked_utf16: Option<(u32, u32)>,
3060    #[serde(default, skip_serializing_if = "Option::is_none")]
3061    pub ime_cursor_area: Option<UiRectV1>,
3062    /// Optional IME surrounding text excerpt metadata (bytes).
3063    ///
3064    /// This is derived from `WindowTextInputSnapshot.surrounding_text` and is intended for
3065    /// lightweight debugging without embedding potentially sensitive text contents in bundles.
3066    #[serde(default, skip_serializing_if = "Option::is_none")]
3067    pub ime_surrounding_text_len_bytes: Option<u32>,
3068    #[serde(default, skip_serializing_if = "Option::is_none")]
3069    pub ime_surrounding_cursor_bytes: Option<u32>,
3070    #[serde(default, skip_serializing_if = "Option::is_none")]
3071    pub ime_surrounding_anchor_bytes: Option<u32>,
3072}
3073
3074#[derive(Debug, Clone, Serialize, Deserialize)]
3075pub struct UiShortcutRoutingTraceEntryV1 {
3076    pub step_index: u32,
3077    #[serde(default, skip_serializing_if = "Option::is_none")]
3078    pub note: Option<String>,
3079    #[serde(default)]
3080    pub frame_id: u64,
3081    pub phase: String,
3082    #[serde(default)]
3083    pub deferred: bool,
3084    #[serde(default)]
3085    pub focus_is_text_input: bool,
3086    #[serde(default)]
3087    pub ime_composing: bool,
3088    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3089    pub key_contexts: Vec<String>,
3090    pub key: String,
3091    pub modifiers: UiKeyModifiersV1,
3092    pub repeat: bool,
3093    pub outcome: String,
3094    #[serde(default, skip_serializing_if = "Option::is_none")]
3095    pub command: Option<String>,
3096    #[serde(default, skip_serializing_if = "Option::is_none")]
3097    pub command_enabled: Option<bool>,
3098    #[serde(default, skip_serializing_if = "Option::is_none")]
3099    pub pending_sequence_len: Option<u32>,
3100}
3101
3102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3103#[serde(rename_all = "snake_case")]
3104pub enum UiOverlayPlacementTraceKindV1 {
3105    AnchoredPanel,
3106    PlacedRect,
3107}
3108
3109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3110pub struct UiShortcutRoutingTraceQueryV1 {
3111    #[serde(default, skip_serializing_if = "Option::is_none")]
3112    pub phase: Option<String>,
3113    #[serde(default, skip_serializing_if = "Option::is_none")]
3114    pub outcome: Option<String>,
3115    #[serde(default, skip_serializing_if = "Option::is_none")]
3116    pub key: Option<String>,
3117    #[serde(default, skip_serializing_if = "Option::is_none")]
3118    pub command: Option<String>,
3119    #[serde(default, skip_serializing_if = "Option::is_none")]
3120    pub ime_composing: Option<bool>,
3121    #[serde(default, skip_serializing_if = "Option::is_none")]
3122    pub focus_is_text_input: Option<bool>,
3123    #[serde(default, skip_serializing_if = "Option::is_none")]
3124    pub key_context: Option<String>,
3125}
3126
3127#[derive(Debug, Clone, Serialize, Deserialize)]
3128pub struct UiCommandDispatchTraceEntryV1 {
3129    pub step_index: u32,
3130    pub frame_id: u64,
3131    pub command: String,
3132    pub handled: bool,
3133    /// Best-effort handler scope classification (ADR 0307).
3134    ///
3135    /// Expected values: `"widget"`, `"window"`, `"app"`.
3136    #[serde(default, skip_serializing_if = "Option::is_none")]
3137    pub handled_by_scope: Option<String>,
3138    /// Whether the command was handled by a runner/driver integration layer (not by a UI element).
3139    #[serde(default)]
3140    pub handled_by_driver: bool,
3141    #[serde(default)]
3142    pub stopped: bool,
3143    #[serde(default)]
3144    pub source_kind: String,
3145    #[serde(default, skip_serializing_if = "Option::is_none")]
3146    pub source_element: Option<u64>,
3147    /// Best-effort stable selector attribution for pointer-triggered dispatch.
3148    ///
3149    /// This is intended to help scripted diagnostics answer:
3150    /// “which `test_id` caused this command to dispatch?”
3151    ///
3152    /// Notes:
3153    /// - This is a best-effort hint (additive). Tooling should fall back to correlating
3154    ///   `source_element` with the semantics snapshot if needed.
3155    /// - When available, this is usually populated from the hit-test trace recorded for the
3156    ///   injected pointer step.
3157    #[serde(default, skip_serializing_if = "Option::is_none")]
3158    pub source_test_id: Option<String>,
3159    #[serde(default, skip_serializing_if = "Option::is_none")]
3160    pub handled_by_element: Option<u64>,
3161    /// Best-effort stable selector attribution for the first widget that handled the command.
3162    #[serde(default, skip_serializing_if = "Option::is_none")]
3163    pub handled_by_test_id: Option<String>,
3164    #[serde(default)]
3165    pub started_from_focus: bool,
3166    #[serde(default)]
3167    pub used_default_root_fallback: bool,
3168}
3169
3170#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3171pub struct UiCommandDispatchTraceQueryV1 {
3172    #[serde(default, skip_serializing_if = "Option::is_none")]
3173    pub command: Option<String>,
3174    #[serde(default, skip_serializing_if = "Option::is_none")]
3175    pub source_kind: Option<String>,
3176    #[serde(default, skip_serializing_if = "Option::is_none")]
3177    pub source_test_id: Option<String>,
3178    #[serde(default, skip_serializing_if = "Option::is_none")]
3179    pub handled: Option<bool>,
3180    #[serde(default, skip_serializing_if = "Option::is_none")]
3181    pub handled_by_scope: Option<String>,
3182    #[serde(default, skip_serializing_if = "Option::is_none")]
3183    pub handled_by_driver: Option<bool>,
3184    #[serde(default, skip_serializing_if = "Option::is_none")]
3185    pub handled_by_test_id: Option<String>,
3186    #[serde(default, skip_serializing_if = "Option::is_none")]
3187    pub started_from_focus: Option<bool>,
3188    #[serde(default, skip_serializing_if = "Option::is_none")]
3189    pub used_default_root_fallback: Option<bool>,
3190}
3191
3192#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
3193#[serde(rename_all = "snake_case")]
3194pub enum UiLayoutDirectionV1 {
3195    Ltr,
3196    Rtl,
3197}
3198
3199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3200#[serde(rename_all = "snake_case")]
3201pub enum UiOverlaySideV1 {
3202    Top,
3203    Bottom,
3204    Left,
3205    Right,
3206}
3207
3208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3209#[serde(rename_all = "snake_case")]
3210pub enum UiOverlayAlignV1 {
3211    Start,
3212    Center,
3213    End,
3214}
3215
3216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3217#[serde(rename_all = "snake_case")]
3218pub enum UiOverlayStickyModeV1 {
3219    Partial,
3220    Always,
3221}
3222
3223#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
3224pub struct UiEdgesV1 {
3225    pub top_px: f32,
3226    pub right_px: f32,
3227    pub bottom_px: f32,
3228    pub left_px: f32,
3229}
3230
3231#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
3232pub struct UiSizeV1 {
3233    pub w_px: f32,
3234    pub h_px: f32,
3235}
3236
3237#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
3238pub struct UiOverlayOffsetV1 {
3239    pub main_axis_px: f32,
3240    pub cross_axis_px: f32,
3241    #[serde(default, skip_serializing_if = "Option::is_none")]
3242    pub alignment_axis_px: Option<f32>,
3243}
3244
3245#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
3246pub struct UiOverlayShiftV1 {
3247    pub main_axis: bool,
3248    pub cross_axis: bool,
3249}
3250
3251#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
3252pub struct UiOverlayArrowLayoutV1 {
3253    pub side: UiOverlaySideV1,
3254    pub offset_px: f32,
3255    pub alignment_offset_px: f32,
3256    pub center_offset_px: f32,
3257}
3258
3259#[derive(Debug, Clone, Serialize, Deserialize)]
3260#[serde(tag = "kind", rename_all = "snake_case")]
3261pub enum UiOverlayPlacementTraceEntryV1 {
3262    AnchoredPanel {
3263        step_index: u32,
3264        #[serde(default, skip_serializing_if = "Option::is_none")]
3265        note: Option<String>,
3266        #[serde(default)]
3267        frame_id: u64,
3268        #[serde(default, skip_serializing_if = "Option::is_none")]
3269        overlay_root_name: Option<String>,
3270        #[serde(default, skip_serializing_if = "Option::is_none")]
3271        anchor_element: Option<u64>,
3272        #[serde(default, skip_serializing_if = "Option::is_none")]
3273        anchor_test_id: Option<String>,
3274        #[serde(default, skip_serializing_if = "Option::is_none")]
3275        content_element: Option<u64>,
3276        #[serde(default, skip_serializing_if = "Option::is_none")]
3277        content_test_id: Option<String>,
3278
3279        outer_input: UiRectV1,
3280        outer_collision: UiRectV1,
3281        anchor: UiRectV1,
3282        desired: UiSizeV1,
3283        side_offset_px: f32,
3284        preferred_side: UiOverlaySideV1,
3285        align: UiOverlayAlignV1,
3286        direction: UiLayoutDirectionV1,
3287        sticky: UiOverlayStickyModeV1,
3288        offset: UiOverlayOffsetV1,
3289        shift: UiOverlayShiftV1,
3290        collision_padding: UiEdgesV1,
3291        #[serde(default, skip_serializing_if = "Option::is_none")]
3292        collision_boundary: Option<UiRectV1>,
3293        gap_px: f32,
3294
3295        preferred_rect: UiRectV1,
3296        flipped_rect: UiRectV1,
3297        #[serde(default)]
3298        preferred_fits_without_main_clamp: bool,
3299        #[serde(default)]
3300        flipped_fits_without_main_clamp: bool,
3301        #[serde(default)]
3302        preferred_available_main_px: f32,
3303        #[serde(default)]
3304        flipped_available_main_px: f32,
3305        chosen_side: UiOverlaySideV1,
3306        chosen_rect: UiRectV1,
3307        rect_after_shift: UiRectV1,
3308        shift_delta: UiPointV1,
3309        final_rect: UiRectV1,
3310        #[serde(default, skip_serializing_if = "Option::is_none")]
3311        arrow: Option<UiOverlayArrowLayoutV1>,
3312    },
3313    PlacedRect {
3314        step_index: u32,
3315        #[serde(default, skip_serializing_if = "Option::is_none")]
3316        note: Option<String>,
3317        #[serde(default)]
3318        frame_id: u64,
3319        #[serde(default, skip_serializing_if = "Option::is_none")]
3320        overlay_root_name: Option<String>,
3321        #[serde(default, skip_serializing_if = "Option::is_none")]
3322        anchor_element: Option<u64>,
3323        #[serde(default, skip_serializing_if = "Option::is_none")]
3324        anchor_test_id: Option<String>,
3325        #[serde(default, skip_serializing_if = "Option::is_none")]
3326        content_element: Option<u64>,
3327        #[serde(default, skip_serializing_if = "Option::is_none")]
3328        content_test_id: Option<String>,
3329        outer: UiRectV1,
3330        anchor: UiRectV1,
3331        placed: UiRectV1,
3332        #[serde(default, skip_serializing_if = "Option::is_none")]
3333        side: Option<UiOverlaySideV1>,
3334    },
3335}
3336
3337#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3338pub struct UiOverlayPlacementTraceQueryV1 {
3339    #[serde(default, skip_serializing_if = "Option::is_none")]
3340    pub kind: Option<UiOverlayPlacementTraceKindV1>,
3341    #[serde(default, skip_serializing_if = "Option::is_none")]
3342    pub overlay_root_name: Option<String>,
3343    #[serde(default, skip_serializing_if = "Option::is_none")]
3344    pub anchor_test_id: Option<String>,
3345    #[serde(default, skip_serializing_if = "Option::is_none")]
3346    pub content_test_id: Option<String>,
3347    #[serde(default, skip_serializing_if = "Option::is_none")]
3348    pub preferred_side: Option<UiOverlaySideV1>,
3349    #[serde(default, skip_serializing_if = "Option::is_none")]
3350    pub chosen_side: Option<UiOverlaySideV1>,
3351    /// For `kind=anchored_panel`, whether the solver flipped away from `preferred_side`.
3352    /// Equivalent to `chosen_side != preferred_side` when both are available.
3353    #[serde(default, skip_serializing_if = "Option::is_none")]
3354    pub flipped: Option<bool>,
3355    #[serde(default, skip_serializing_if = "Option::is_none")]
3356    pub align: Option<UiOverlayAlignV1>,
3357    #[serde(default, skip_serializing_if = "Option::is_none")]
3358    pub sticky: Option<UiOverlayStickyModeV1>,
3359}
3360
3361/// Debug-only snapshot for the wasm textarea IME bridge (ADR 0180).
3362///
3363/// This is intended for diagnostics evidence and is not a normative contract surface.
3364#[derive(Debug, Clone, Serialize, Deserialize)]
3365pub struct UiWebImeTraceEntryV1 {
3366    pub step_index: u32,
3367    #[serde(default, skip_serializing_if = "Option::is_none")]
3368    pub note: Option<String>,
3369
3370    #[serde(default)]
3371    pub enabled: bool,
3372    #[serde(default)]
3373    pub composing: bool,
3374    #[serde(default)]
3375    pub suppress_next_input: bool,
3376
3377    #[serde(default, skip_serializing_if = "Option::is_none")]
3378    pub textarea_has_focus: Option<bool>,
3379    #[serde(default, skip_serializing_if = "Option::is_none")]
3380    pub active_element_tag: Option<String>,
3381
3382    #[serde(default, skip_serializing_if = "Option::is_none")]
3383    pub position_mode: Option<String>,
3384    #[serde(default, skip_serializing_if = "Option::is_none")]
3385    pub mount_kind: Option<String>,
3386    #[serde(default, skip_serializing_if = "Option::is_none")]
3387    pub device_pixel_ratio: Option<f64>,
3388
3389    #[serde(default, skip_serializing_if = "Option::is_none")]
3390    pub textarea_selection_start_utf16: Option<u32>,
3391    #[serde(default, skip_serializing_if = "Option::is_none")]
3392    pub textarea_selection_end_utf16: Option<u32>,
3393
3394    #[serde(default, skip_serializing_if = "Option::is_none")]
3395    pub last_cursor_area: Option<UiRectV1>,
3396    #[serde(default, skip_serializing_if = "Option::is_none")]
3397    pub last_cursor_anchor_px: Option<(f32, f32)>,
3398
3399    #[serde(default, skip_serializing_if = "Option::is_none")]
3400    pub last_input_type: Option<String>,
3401
3402    #[serde(default, skip_serializing_if = "Option::is_none")]
3403    pub last_preedit_len: Option<u32>,
3404    #[serde(default, skip_serializing_if = "Option::is_none")]
3405    pub last_preedit_cursor_utf16: Option<(u32, u32)>,
3406    #[serde(default, skip_serializing_if = "Option::is_none")]
3407    pub last_commit_len: Option<u32>,
3408
3409    #[serde(default)]
3410    pub beforeinput_seen: u64,
3411    #[serde(default)]
3412    pub input_seen: u64,
3413    #[serde(default)]
3414    pub suppressed_input_seen: u64,
3415    #[serde(default)]
3416    pub composition_start_seen: u64,
3417    #[serde(default)]
3418    pub composition_update_seen: u64,
3419    #[serde(default)]
3420    pub composition_end_seen: u64,
3421    #[serde(default)]
3422    pub cursor_area_set_seen: u64,
3423}
3424
3425#[derive(Debug, Clone, Serialize, Deserialize)]
3426pub struct UiImeEventTraceEntryV1 {
3427    pub step_index: u32,
3428    #[serde(default, skip_serializing_if = "Option::is_none")]
3429    pub note: Option<String>,
3430    pub kind: String,
3431    #[serde(default, skip_serializing_if = "Option::is_none")]
3432    pub preedit_len: Option<u32>,
3433    #[serde(default, skip_serializing_if = "Option::is_none")]
3434    pub preedit_cursor: Option<(u32, u32)>,
3435    #[serde(default, skip_serializing_if = "Option::is_none")]
3436    pub commit_len: Option<u32>,
3437    #[serde(default, skip_serializing_if = "Option::is_none")]
3438    pub delete_surrounding: Option<(u32, u32)>,
3439}
3440
3441#[derive(Debug, Clone, Serialize, Deserialize)]
3442#[serde(rename_all = "snake_case")]
3443pub enum UiScriptStageV1 {
3444    Queued,
3445    Running,
3446    Passed,
3447    Failed,
3448}
3449
3450fn serde_default_true() -> bool {
3451    true
3452}
3453
3454fn serde_default_one_f64() -> f64 {
3455    1.0
3456}
3457
3458fn default_diag_screenshot_schema_version() -> u32 {
3459    1
3460}
3461
3462fn is_zero_u64(v: &u64) -> bool {
3463    *v == 0
3464}
3465
3466#[cfg(test)]
3467mod tests {
3468    use super::*;
3469
3470    #[test]
3471    fn devtools_app_exit_request_serializes_minimally() {
3472        let value = serde_json::to_value(DevtoolsAppExitRequestV1 {
3473            schema_version: 1,
3474            reason: None,
3475            delay_ms: None,
3476        })
3477        .unwrap();
3478        assert_eq!(value, serde_json::json!({ "schema_version": 1 }));
3479    }
3480
3481    #[test]
3482    fn predicate_runner_accessibility_activated_serializes_and_deserializes() {
3483        let value = serde_json::to_value(UiPredicateV1::RunnerAccessibilityActivated).unwrap();
3484        assert_eq!(
3485            value,
3486            serde_json::json!({ "kind": "runner_accessibility_activated" })
3487        );
3488
3489        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3490        assert!(matches!(
3491            roundtrip,
3492            UiPredicateV1::RunnerAccessibilityActivated
3493        ));
3494    }
3495
3496    #[test]
3497    fn predicate_app_snapshot_field_equals_serializes_minimally() {
3498        let value = serde_json::to_value(UiPredicateV1::AppSnapshotFieldEquals {
3499            pointer: "/shell/settings_open".to_string(),
3500            value: serde_json::json!(true),
3501        })
3502        .unwrap();
3503
3504        assert_eq!(
3505            value,
3506            serde_json::json!({
3507                "kind": "app_snapshot_field_equals",
3508                "pointer": "/shell/settings_open",
3509                "value": true,
3510            })
3511        );
3512
3513        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3514        assert!(matches!(
3515            roundtrip,
3516            UiPredicateV1::AppSnapshotFieldEquals { .. }
3517        ));
3518    }
3519
3520    #[test]
3521    fn predicate_bounds_approx_equal_serializes_and_deserializes() {
3522        let value = serde_json::to_value(UiPredicateV1::BoundsApproxEqual {
3523            a: UiSelectorV1::TestId {
3524                id: "a".to_string(),
3525                root_z_index: None,
3526            },
3527            b: UiSelectorV1::TestId {
3528                id: "b".to_string(),
3529                root_z_index: None,
3530            },
3531            eps_px: 1.0,
3532        })
3533        .unwrap();
3534
3535        assert_eq!(
3536            value,
3537            serde_json::json!({
3538                "kind": "bounds_approx_equal",
3539                "a": { "kind": "test_id", "id": "a" },
3540                "b": { "kind": "test_id", "id": "b" },
3541                "eps_px": 1.0
3542            })
3543        );
3544
3545        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3546        assert!(matches!(roundtrip, UiPredicateV1::BoundsApproxEqual { .. }));
3547    }
3548
3549    #[test]
3550    fn predicate_bounds_center_approx_equal_serializes_and_deserializes() {
3551        let value = serde_json::to_value(UiPredicateV1::BoundsCenterApproxEqual {
3552            a: UiSelectorV1::TestId {
3553                id: "a".to_string(),
3554                root_z_index: None,
3555            },
3556            b: UiSelectorV1::TestId {
3557                id: "b".to_string(),
3558                root_z_index: None,
3559            },
3560            eps_px: 1.0,
3561        })
3562        .unwrap();
3563
3564        assert_eq!(
3565            value,
3566            serde_json::json!({
3567                "kind": "bounds_center_approx_equal",
3568                "a": { "kind": "test_id", "id": "a" },
3569                "b": { "kind": "test_id", "id": "b" },
3570                "eps_px": 1.0
3571            })
3572        );
3573
3574        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3575        assert!(matches!(
3576            roundtrip,
3577            UiPredicateV1::BoundsCenterApproxEqual { .. }
3578        ));
3579    }
3580
3581    #[test]
3582    fn predicate_exists_under_serializes_minimally() {
3583        let value = serde_json::to_value(UiPredicateV1::ExistsUnder {
3584            scope: UiSelectorV1::TestId {
3585                id: "scope".to_string(),
3586                root_z_index: None,
3587            },
3588            target: UiSelectorV1::TestId {
3589                id: "target".to_string(),
3590                root_z_index: None,
3591            },
3592        })
3593        .unwrap();
3594
3595        assert_eq!(
3596            value,
3597            serde_json::json!({
3598                "kind": "exists_under",
3599                "scope": { "kind": "test_id", "id": "scope" },
3600                "target": { "kind": "test_id", "id": "target" },
3601            })
3602        );
3603
3604        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3605        assert!(matches!(roundtrip, UiPredicateV1::ExistsUnder { .. }));
3606    }
3607
3608    #[test]
3609    fn predicate_value_equals_serializes_minimally() {
3610        let value = serde_json::to_value(UiPredicateV1::ValueEquals {
3611            target: UiSelectorV1::TestId {
3612                id: "name".to_string(),
3613                root_z_index: None,
3614            },
3615            text: "Alice".to_string(),
3616        })
3617        .unwrap();
3618
3619        assert_eq!(
3620            value,
3621            serde_json::json!({
3622                "kind": "value_equals",
3623                "target": { "kind": "test_id", "id": "name" },
3624                "text": "Alice",
3625            })
3626        );
3627
3628        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3629        assert!(matches!(roundtrip, UiPredicateV1::ValueEquals { .. }));
3630    }
3631
3632    #[test]
3633    fn predicate_focused_descendant_is_serializes_minimally() {
3634        let value = serde_json::to_value(UiPredicateV1::FocusedDescendantIs {
3635            scope: UiSelectorV1::TestId {
3636                id: "dialog".to_string(),
3637                root_z_index: None,
3638            },
3639            target: UiSelectorV1::TestId {
3640                id: "close".to_string(),
3641                root_z_index: None,
3642            },
3643        })
3644        .unwrap();
3645
3646        assert_eq!(
3647            value,
3648            serde_json::json!({
3649                "kind": "focused_descendant_is",
3650                "scope": { "kind": "test_id", "id": "dialog" },
3651                "target": { "kind": "test_id", "id": "close" },
3652            })
3653        );
3654
3655        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3656        assert!(matches!(
3657            roundtrip,
3658            UiPredicateV1::FocusedDescendantIs { .. }
3659        ));
3660    }
3661
3662    #[test]
3663    fn predicate_dock_tab_strip_scroll_predicates_serialize_and_deserialize() {
3664        let value =
3665            serde_json::to_value(UiPredicateV1::DockTabStripActiveScrollPxGe { px: 12.0 }).unwrap();
3666        assert_eq!(
3667            value,
3668            serde_json::json!({
3669                "kind": "dock_tab_strip_active_scroll_px_ge",
3670                "px": 12.0
3671            })
3672        );
3673        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3674        assert!(matches!(
3675            roundtrip,
3676            UiPredicateV1::DockTabStripActiveScrollPxGe { .. }
3677        ));
3678
3679        let value =
3680            serde_json::to_value(UiPredicateV1::DockTabStripActiveScrollPxLe { px: 0.0 }).unwrap();
3681        assert_eq!(
3682            value,
3683            serde_json::json!({
3684                "kind": "dock_tab_strip_active_scroll_px_le",
3685                "px": 0.0
3686            })
3687        );
3688        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3689        assert!(matches!(
3690            roundtrip,
3691            UiPredicateV1::DockTabStripActiveScrollPxLe { .. }
3692        ));
3693    }
3694
3695    #[test]
3696    fn predicate_workspace_tab_strip_scroll_predicates_serialize_and_deserialize() {
3697        let value = serde_json::to_value(UiPredicateV1::WorkspaceTabStripActiveScrollPxGe {
3698            px: 12.0,
3699            pane_id: None,
3700        })
3701        .unwrap();
3702        assert_eq!(
3703            value,
3704            serde_json::json!({
3705                "kind": "workspace_tab_strip_active_scroll_px_ge",
3706                "px": 12.0
3707            })
3708        );
3709        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3710        assert!(matches!(
3711            roundtrip,
3712            UiPredicateV1::WorkspaceTabStripActiveScrollPxGe { .. }
3713        ));
3714
3715        let value = serde_json::to_value(UiPredicateV1::WorkspaceTabStripActiveScrollPxLe {
3716            px: 0.0,
3717            pane_id: Some("pane-a".to_string()),
3718        })
3719        .unwrap();
3720        assert_eq!(
3721            value,
3722            serde_json::json!({
3723                "kind": "workspace_tab_strip_active_scroll_px_le",
3724                "px": 0.0,
3725                "pane_id": "pane-a",
3726            })
3727        );
3728        let roundtrip: UiPredicateV1 = serde_json::from_value(value).unwrap();
3729        assert!(matches!(
3730            roundtrip,
3731            UiPredicateV1::WorkspaceTabStripActiveScrollPxLe { .. }
3732        ));
3733    }
3734
3735    #[test]
3736    fn diag_screenshot_request_round_trips_and_defaults_scale_factor() {
3737        let json = serde_json::json!({
3738            "schema_version": 1,
3739            "out_dir": "target/fret-diag",
3740            "bundle_dir_name": "1700000-bundle",
3741            "request_id": "req-1",
3742            "windows": [{
3743                "window": 123,
3744                "tick_id": 1,
3745                "frame_id": 2
3746            }]
3747        });
3748        let parsed: DiagScreenshotRequestV1 = serde_json::from_value(json).unwrap();
3749        assert_eq!(parsed.schema_version, 1);
3750        assert_eq!(parsed.windows.len(), 1);
3751        assert_eq!(parsed.windows[0].scale_factor, 1.0);
3752
3753        let value = serde_json::to_value(parsed).unwrap();
3754        assert_eq!(value["schema_version"].as_u64(), Some(1));
3755    }
3756
3757    #[test]
3758    fn diag_screenshot_result_defaults_schema_version_to_1() {
3759        let value = serde_json::json!({
3760            "updated_unix_ms": 1700000,
3761            "completed": [],
3762        });
3763        let parsed: DiagScreenshotResultFileV1 = serde_json::from_value(value).unwrap();
3764        assert_eq!(parsed.schema_version, 1);
3765        assert_eq!(DiagScreenshotResultFileV1::default().schema_version, 1);
3766    }
3767
3768    #[test]
3769    fn click_step_pointer_kind_round_trips_and_omits_none() {
3770        let step = UiActionStepV2::Click {
3771            window: None,
3772            pointer_kind: None,
3773            target: UiSelectorV1::TestId {
3774                id: "a".to_string(),
3775                root_z_index: None,
3776            },
3777            button: UiMouseButtonV1::Left,
3778            click_count: 1,
3779            modifiers: None,
3780        };
3781        let value = serde_json::to_value(step.clone()).unwrap();
3782        assert_eq!(
3783            value,
3784            serde_json::json!({
3785              "type": "click",
3786              "target": {"kind":"test_id","id":"a"},
3787              "button": "left"
3788            })
3789        );
3790
3791        let parsed: UiActionStepV2 = serde_json::from_value(serde_json::json!({
3792          "type": "click",
3793          "pointer_kind": "touch",
3794          "target": {"kind":"test_id","id":"a"},
3795          "button": "left",
3796          "click_count": 1
3797        }))
3798        .unwrap();
3799        assert!(matches!(
3800            parsed,
3801            UiActionStepV2::Click {
3802                pointer_kind: Some(UiPointerKindV1::Touch),
3803                ..
3804            }
3805        ));
3806    }
3807
3808    #[test]
3809    fn tap_step_pointer_kind_round_trips_and_omits_none() {
3810        let step = UiActionStepV2::Tap {
3811            window: None,
3812            pointer_kind: None,
3813            target: UiSelectorV1::TestId {
3814                id: "a".to_string(),
3815                root_z_index: None,
3816            },
3817            modifiers: None,
3818        };
3819        let value = serde_json::to_value(step.clone()).unwrap();
3820        assert_eq!(
3821            value,
3822            serde_json::json!({
3823              "type": "tap",
3824              "target": {"kind":"test_id","id":"a"}
3825            })
3826        );
3827
3828        let parsed: UiActionStepV2 = serde_json::from_value(serde_json::json!({
3829          "type": "tap",
3830          "pointer_kind": "pen",
3831          "target": {"kind":"test_id","id":"a"}
3832        }))
3833        .unwrap();
3834        assert!(matches!(
3835            parsed,
3836            UiActionStepV2::Tap {
3837                pointer_kind: Some(UiPointerKindV1::Pen),
3838                ..
3839            }
3840        ));
3841    }
3842
3843    #[test]
3844    fn long_press_step_round_trips_and_omits_defaults() {
3845        let step = UiActionStepV2::LongPress {
3846            window: None,
3847            pointer_kind: None,
3848            target: UiSelectorV1::TestId {
3849                id: "a".to_string(),
3850                root_z_index: None,
3851            },
3852            duration_ms: default_long_press_duration_ms(),
3853            modifiers: None,
3854        };
3855        let value = serde_json::to_value(step.clone()).unwrap();
3856        assert_eq!(
3857            value,
3858            serde_json::json!({
3859              "type": "long_press",
3860              "target": {"kind":"test_id","id":"a"}
3861            })
3862        );
3863
3864        let parsed: UiActionStepV2 = serde_json::from_value(serde_json::json!({
3865          "type": "long_press",
3866          "pointer_kind": "pen",
3867          "target": {"kind":"test_id","id":"a"},
3868          "duration_ms": 125
3869        }))
3870        .unwrap();
3871        assert!(matches!(
3872            parsed,
3873            UiActionStepV2::LongPress {
3874                pointer_kind: Some(UiPointerKindV1::Pen),
3875                duration_ms: 125,
3876                ..
3877            }
3878        ));
3879    }
3880
3881    #[test]
3882    fn swipe_step_round_trips_and_omits_defaults() {
3883        let step = UiActionStepV2::Swipe {
3884            window: None,
3885            pointer_kind: None,
3886            target: UiSelectorV1::TestId {
3887                id: "a".to_string(),
3888                root_z_index: None,
3889            },
3890            delta_x: 12.0,
3891            delta_y: -8.0,
3892            steps: default_drag_steps(),
3893            modifiers: None,
3894        };
3895        let value = serde_json::to_value(step.clone()).unwrap();
3896        assert_eq!(
3897            value,
3898            serde_json::json!({
3899              "type": "swipe",
3900              "target": {"kind":"test_id","id":"a"},
3901              "delta_x": 12.0,
3902              "delta_y": -8.0
3903            })
3904        );
3905
3906        let parsed: UiActionStepV2 = serde_json::from_value(serde_json::json!({
3907          "type": "swipe",
3908          "pointer_kind": "pen",
3909          "target": {"kind":"test_id","id":"a"},
3910          "delta_x": 1.0,
3911          "delta_y": 2.0,
3912          "steps": 3
3913        }))
3914        .unwrap();
3915        assert!(matches!(
3916            parsed,
3917            UiActionStepV2::Swipe {
3918                pointer_kind: Some(UiPointerKindV1::Pen),
3919                steps: 3,
3920                ..
3921            }
3922        ));
3923    }
3924
3925    #[test]
3926    fn step_activate_deserializes_with_defaults() {
3927        let value = serde_json::json!({
3928            "type": "activate",
3929            "target": { "kind": "test_id", "id": "trigger" }
3930        });
3931
3932        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
3933        match step {
3934            UiActionStepV2::Activate { window, target } => {
3935                assert!(window.is_none());
3936                assert!(matches!(target, UiSelectorV1::TestId { .. }));
3937            }
3938            _ => panic!("expected activate"),
3939        }
3940    }
3941
3942    #[test]
3943    fn step_focus_deserializes_with_defaults() {
3944        let value = serde_json::json!({
3945            "type": "focus",
3946            "target": { "kind": "test_id", "id": "trigger" }
3947        });
3948
3949        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
3950        match step {
3951            UiActionStepV2::Focus { window, target } => {
3952                assert!(window.is_none());
3953                assert!(matches!(target, UiSelectorV1::TestId { .. }));
3954            }
3955            _ => panic!("expected focus"),
3956        }
3957    }
3958
3959    #[test]
3960    fn hit_test_explain_request_round_trips() {
3961        let value = serde_json::json!({
3962            "schema_version": 1,
3963            "window": 7,
3964            "target": { "kind": "test_id", "id": "trigger" }
3965        });
3966        let req: UiHitTestExplainV1 = serde_json::from_value(value.clone()).unwrap();
3967        assert_eq!(req.window, 7);
3968        assert_eq!(serde_json::to_value(req).unwrap(), value);
3969    }
3970
3971    #[test]
3972    fn step_paste_text_into_deserializes_with_defaults() {
3973        let value = serde_json::json!({
3974            "type": "paste_text_into",
3975            "target": { "kind": "test_id", "id": "field" },
3976            "text": "Hello"
3977        });
3978
3979        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
3980        match step {
3981            UiActionStepV2::PasteTextInto {
3982                window,
3983                pointer_kind,
3984                target,
3985                text,
3986                clear_before_paste,
3987                timeout_frames,
3988            } => {
3989                assert!(window.is_none());
3990                assert!(pointer_kind.is_none());
3991                assert!(matches!(target, UiSelectorV1::TestId { .. }));
3992                assert_eq!(text, "Hello");
3993                assert!(!clear_before_paste);
3994                assert_eq!(timeout_frames, default_action_timeout_frames());
3995            }
3996            _ => panic!("expected paste_text_into"),
3997        }
3998    }
3999
4000    #[test]
4001    fn step_set_text_value_deserializes_with_defaults() {
4002        let value = serde_json::json!({
4003            "type": "set_text_value",
4004            "target": { "kind": "test_id", "id": "field" },
4005            "text": "#112233"
4006        });
4007
4008        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4009        match step {
4010            UiActionStepV2::SetTextValue {
4011                window,
4012                target,
4013                text,
4014                timeout_frames,
4015            } => {
4016                assert!(window.is_none());
4017                assert!(matches!(target, UiSelectorV1::TestId { .. }));
4018                assert_eq!(text, "#112233");
4019                assert_eq!(timeout_frames, default_action_timeout_frames());
4020            }
4021            _ => panic!("expected set_text_value"),
4022        }
4023    }
4024
4025    #[test]
4026    fn step_wait_clipboard_write_result_deserializes_with_defaults() {
4027        let value = serde_json::json!({
4028            "type": "wait_clipboard_write_result",
4029            "outcome": "success"
4030        });
4031
4032        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4033        match step {
4034            UiActionStepV2::WaitClipboardWriteResult {
4035                outcome,
4036                error_kind,
4037                message_contains,
4038                timeout_frames,
4039            } => {
4040                assert_eq!(outcome, UiClipboardWriteResultV1::Success);
4041                assert!(error_kind.is_none());
4042                assert!(message_contains.is_none());
4043                assert_eq!(timeout_frames, default_action_timeout_frames());
4044            }
4045            _ => panic!("expected wait_clipboard_write_result"),
4046        }
4047    }
4048
4049    #[test]
4050    fn step_assert_clipboard_write_result_deserializes_with_failure_details() {
4051        let value = serde_json::json!({
4052            "type": "assert_clipboard_write_result",
4053            "outcome": "failure",
4054            "error_kind": "unavailable",
4055            "message_contains": "forced clipboard unavailable",
4056            "timeout_frames": 90
4057        });
4058
4059        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4060        match step {
4061            UiActionStepV2::AssertClipboardWriteResult {
4062                outcome,
4063                error_kind,
4064                message_contains,
4065                timeout_frames,
4066            } => {
4067                assert_eq!(outcome, UiClipboardWriteResultV1::Failure);
4068                assert_eq!(error_kind, Some(UiClipboardAccessErrorKindV1::Unavailable));
4069                assert_eq!(
4070                    message_contains.as_deref(),
4071                    Some("forced clipboard unavailable")
4072                );
4073                assert_eq!(timeout_frames, 90);
4074            }
4075            _ => panic!("expected assert_clipboard_write_result"),
4076        }
4077    }
4078
4079    #[test]
4080    fn step_inspect_help_lock_best_match_and_copy_selector_deserializes_with_defaults() {
4081        let value = serde_json::json!({
4082            "type": "inspect_help_lock_best_match_and_copy_selector",
4083            "query": "ui-gallery-nav-search"
4084        });
4085
4086        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4087        match step {
4088            UiActionStepV2::InspectHelpLockBestMatchAndCopySelector {
4089                window,
4090                query,
4091                timeout_frames,
4092            } => {
4093                assert!(window.is_none());
4094                assert_eq!(query, "ui-gallery-nav-search");
4095                assert_eq!(timeout_frames, default_action_timeout_frames());
4096            }
4097            _ => panic!("expected inspect_help_lock_best_match_and_copy_selector"),
4098        }
4099    }
4100
4101    #[test]
4102    fn step_inspect_help_tree_lock_best_match_and_copy_selector_deserializes_with_defaults() {
4103        let value = serde_json::json!({
4104            "type": "inspect_help_tree_lock_best_match_and_copy_selector",
4105            "query": "ui-gallery-nav-search"
4106        });
4107
4108        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4109        match step {
4110            UiActionStepV2::InspectHelpTreeLockBestMatchAndCopySelector {
4111                window,
4112                query,
4113                timeout_frames,
4114            } => {
4115                assert!(window.is_none());
4116                assert_eq!(query, "ui-gallery-nav-search");
4117                assert_eq!(timeout_frames, default_action_timeout_frames());
4118            }
4119            _ => panic!("expected inspect_help_tree_lock_best_match_and_copy_selector"),
4120        }
4121    }
4122
4123    #[test]
4124    fn step_wait_until_deserializes_with_default_timeout_frames() {
4125        let value = serde_json::json!({
4126            "type": "wait_until",
4127            "predicate": {
4128                "kind": "exists",
4129                "target": { "kind": "test_id", "id": "ui-gallery-nav-search" }
4130            }
4131        });
4132
4133        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4134        match step {
4135            UiActionStepV2::WaitUntil {
4136                window,
4137                predicate,
4138                timeout_frames,
4139                timeout_ms,
4140            } => {
4141                assert!(window.is_none());
4142                assert!(
4143                    matches!(predicate, UiPredicateV1::Exists { .. }),
4144                    "expected exists predicate"
4145                );
4146                assert_eq!(timeout_frames, default_action_timeout_frames());
4147                assert!(timeout_ms.is_none());
4148            }
4149            _ => panic!("expected wait_until"),
4150        }
4151    }
4152
4153    #[test]
4154    fn step_wait_semantics_scroll_stable_deserializes_with_defaults() {
4155        let value = serde_json::json!({
4156            "type": "wait_semantics_scroll_stable",
4157            "target": { "kind": "test_id", "id": "ui-gallery-content-viewport" },
4158            "field": "y_max"
4159        });
4160
4161        let step: UiActionStepV2 = serde_json::from_value(value).unwrap();
4162        match step {
4163            UiActionStepV2::WaitSemanticsScrollStable {
4164                window,
4165                target,
4166                field,
4167                stable_frames,
4168                max_delta,
4169                timeout_frames,
4170            } => {
4171                assert!(window.is_none());
4172                assert!(matches!(target, UiSelectorV1::TestId { .. }));
4173                assert_eq!(field, UiSemanticsScrollFieldV1::YMax);
4174                assert_eq!(stable_frames, default_semantics_scroll_stable_frames());
4175                assert_eq!(max_delta, default_semantics_scroll_stable_max_delta());
4176                assert_eq!(timeout_frames, default_action_timeout_frames());
4177            }
4178            _ => panic!("expected wait_semantics_scroll_stable"),
4179        }
4180    }
4181}