Skip to main content

smix_adapter_maestro/
lib.rs

1//! smix-adapter-maestro — Maestro YAML adapter for smix.
2//!
3//! Parses Maestro test flow YAML and translates each [`Step`] into a
4//! `smix-sdk` action call. See `README.md` for the design rationale.
5//!
6//! [`parse_flow_yaml`] does single-file parsing; [`parse_flow_file`]
7//! adds recursive `runFlow` expansion with cycle detection.
8
9#![forbid(unsafe_code)]
10#![warn(missing_docs)]
11
12use serde::{Deserialize, Serialize};
13use smix_selector::Selector;
14use std::path::PathBuf;
15
16/// One annotation composed onto a screenshot output. Position
17/// resolves against pixel/normalized coords or a smix `Selector`
18/// (adapter runtime resolves selector → pixel at screenshot time via
19/// the a11y tree).
20///
21/// Serialization: externally-tagged with PascalCase (matches
22/// `parse_annotation_from_kind`'s wrapper).
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24pub enum AnnotationSpec {
25    /// Filled circle overlay.
26    Circle {
27        /// Center position.
28        at: AnnotationPos,
29        /// Color spec (named / #hex / rgb() / rgba()).
30        #[serde(default = "default_annotation_color")]
31        color: String,
32        /// Radius in pixels.
33        #[serde(default = "default_annotation_radius")]
34        radius: i32,
35        /// Stroke width in pixels.
36        #[serde(default = "default_annotation_stroke")]
37        stroke: i32,
38    },
39    /// Line from → to.
40    Line {
41        /// Line start position.
42        from: AnnotationPos,
43        /// Line end position.
44        to: AnnotationPos,
45        /// Color spec.
46        #[serde(default = "default_annotation_color_line")]
47        color: String,
48        /// Stroke width.
49        #[serde(default = "default_annotation_stroke_line")]
50        stroke: i32,
51    },
52    /// Arrow from → to.
53    Arrow {
54        /// Arrow start position.
55        from: AnnotationPos,
56        /// Arrow tip position.
57        to: AnnotationPos,
58        /// Color spec.
59        #[serde(default = "default_annotation_color_line")]
60        color: String,
61        /// Stroke width.
62        #[serde(default = "default_annotation_stroke_arrow")]
63        stroke: i32,
64    },
65    /// Text label.
66    Text {
67        /// Text baseline start position.
68        at: AnnotationPos,
69        /// Text content.
70        content: String,
71        /// Color spec.
72        #[serde(default = "default_annotation_color_text")]
73        color: String,
74        /// Font size in pixels.
75        #[serde(default = "default_annotation_font_size")]
76        size: f32,
77    },
78    /// Rectangle outline.
79    Box {
80        /// Top-left position.
81        at: AnnotationPos,
82        /// Width in pixels.
83        width: i32,
84        /// Height in pixels.
85        height: i32,
86        /// Color spec.
87        #[serde(default = "default_annotation_color_line")]
88        color: String,
89        /// Stroke width.
90        #[serde(default = "default_annotation_stroke_line")]
91        stroke: i32,
92    },
93}
94
95/// Position spec for an annotation. Three shapes:
96/// - `{ x: 100, y: 100 }` — absolute pixel
97/// - `{ nx: 0.5, ny: 0.5 }` — normalized 0..1 (viewport-relative)
98/// - `{ id: "submit-btn" }` — smix Selector — resolved against a11y
99///   tree at capture time; center of the matched element used
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101#[serde(untagged)]
102pub enum AnnotationPos {
103    /// Absolute pixel position.
104    Pixel {
105        /// X coordinate in pixels.
106        x: i32,
107        /// Y coordinate in pixels.
108        y: i32,
109    },
110    /// Normalized 0..1 position (viewport-relative).
111    Normalized {
112        /// Normalized X (0.0 = left, 1.0 = right).
113        nx: f32,
114        /// Normalized Y (0.0 = top, 1.0 = bottom).
115        ny: f32,
116    },
117    /// Selector — resolved to element center at render time via a11y tree.
118    Selector(Selector),
119}
120
121fn default_annotation_color() -> String {
122    "red".into()
123}
124fn default_annotation_color_line() -> String {
125    "cyan".into()
126}
127fn default_annotation_color_text() -> String {
128    "white".into()
129}
130fn default_annotation_radius() -> i32 {
131    30
132}
133fn default_annotation_stroke() -> i32 {
134    3
135}
136fn default_annotation_stroke_line() -> i32 {
137    2
138}
139fn default_annotation_stroke_arrow() -> i32 {
140    4
141}
142fn default_annotation_font_size() -> f32 {
143    20.0
144}
145
146/// Parse an [`AnnotationSpec`] from a yaml annotation
147/// entry like `circle: { at: ..., color: ..., radius: ... }`. `kind`
148/// is the outer key (`circle`/`arrow`/`text`/`box`/`line`); `body` is
149/// the mapping value. Returns human-readable error string on shape
150/// mismatch for the parser to wrap into `ParseError::InvalidValue`.
151pub(crate) fn parse_annotation_from_kind(
152    kind: &str,
153    body: &serde_norway::Value,
154) -> Result<AnnotationSpec, String> {
155    let m = body
156        .as_mapping()
157        .ok_or_else(|| format!("expected mapping body for `{kind}`, got {body:?}"))?;
158    let get_str = |field: &str| -> Option<String> {
159        m.get(serde_norway::Value::String(field.into()))?
160            .as_str()
161            .map(String::from)
162    };
163    let get_i32 = |field: &str, default: i32| -> Result<i32, String> {
164        match m.get(serde_norway::Value::String(field.into())) {
165            None => Ok(default),
166            Some(v) => v
167                .as_i64()
168                .map(|n| n as i32)
169                .ok_or_else(|| format!("`{field}` must be integer, got {v:?}")),
170        }
171    };
172    let get_f32 = |field: &str, default: f32| -> Result<f32, String> {
173        match m.get(serde_norway::Value::String(field.into())) {
174            None => Ok(default),
175            Some(v) => v
176                .as_f64()
177                .or_else(|| v.as_i64().map(|n| n as f64))
178                .map(|n| n as f32)
179                .ok_or_else(|| format!("`{field}` must be number, got {v:?}")),
180        }
181    };
182    let get_pos = |field: &str| -> Result<AnnotationPos, String> {
183        let v = m
184            .get(serde_norway::Value::String(field.into()))
185            .ok_or_else(|| format!("missing `{field}`"))?;
186        let vm = v
187            .as_mapping()
188            .ok_or_else(|| format!("`{field}` must be mapping, got {v:?}"))?;
189        if let (Some(x), Some(y)) = (
190            vm.get(serde_norway::Value::String("x".into()))
191                .and_then(|v| v.as_i64()),
192            vm.get(serde_norway::Value::String("y".into()))
193                .and_then(|v| v.as_i64()),
194        ) {
195            return Ok(AnnotationPos::Pixel {
196                x: x as i32,
197                y: y as i32,
198            });
199        }
200        if let (Some(nx), Some(ny)) = (
201            vm.get(serde_norway::Value::String("nx".into()))
202                .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
203            vm.get(serde_norway::Value::String("ny".into()))
204                .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
205        ) {
206            return Ok(AnnotationPos::Normalized {
207                nx: nx as f32,
208                ny: ny as f32,
209            });
210        }
211        Err(format!(
212            "`{field}` must have {{x, y}} (pixel) or {{nx, ny}} (normalized) — got {v:?}"
213        ))
214    };
215
216    match kind {
217        "circle" => Ok(AnnotationSpec::Circle {
218            at: get_pos("at")?,
219            color: get_str("color").unwrap_or_else(default_annotation_color),
220            radius: get_i32("radius", default_annotation_radius())?,
221            stroke: get_i32("stroke", default_annotation_stroke())?,
222        }),
223        "arrow" => Ok(AnnotationSpec::Arrow {
224            from: get_pos("from")?,
225            to: get_pos("to")?,
226            color: get_str("color").unwrap_or_else(default_annotation_color_line),
227            stroke: get_i32("stroke", default_annotation_stroke_arrow())?,
228        }),
229        "text" => Ok(AnnotationSpec::Text {
230            at: get_pos("at")?,
231            content: get_str("content").ok_or_else(|| "missing `content`".to_string())?,
232            color: get_str("color").unwrap_or_else(default_annotation_color_text),
233            size: get_f32("size", default_annotation_font_size())?,
234        }),
235        "box" => Ok(AnnotationSpec::Box {
236            at: get_pos("at")?,
237            width: get_i32("width", 100)?,
238            height: get_i32("height", 100)?,
239            color: get_str("color").unwrap_or_else(default_annotation_color_line),
240            stroke: get_i32("stroke", default_annotation_stroke_line())?,
241        }),
242        "line" => Ok(AnnotationSpec::Line {
243            from: get_pos("from")?,
244            to: get_pos("to")?,
245            color: get_str("color").unwrap_or_else(default_annotation_color_line),
246            stroke: get_i32("stroke", default_annotation_stroke_line())?,
247        }),
248        other => Err(format!("unknown annotation kind `{other}`")),
249    }
250}
251
252mod annotate_bridge;
253mod apps_config;
254mod emitter;
255mod entry;
256mod expr;
257mod output;
258mod parser;
259mod runtime;
260
261pub use emitter::{EmitError, emit_flow_yaml};
262
263pub use entry::{FlowArgs, FlowPlatform, OutputFormat, run_flow, run_flow_code};
264
265pub use apps_config::{
266    AndroidApp, AppEntry, AppsConfig, IosApp, ResolveError, ResolvedApp, resolve_app_into_flow,
267};
268
269pub(crate) use expr::{Context as ExprContext, parse_and_eval as expr_eval};
270
271/// The yaml expression engine's `Value` type. Callers use it to build
272/// the output store passed to `Adapter::with_output`. The exposed
273/// surface is a stable subset (Null / Bool / Number / String).
274pub use expr::Value as ExprValue;
275
276pub use parser::{
277    parse_flow_file, parse_flow_yaml, set_ai_assertions_override, set_auto_ocr_fallback_override,
278    text_to_pattern, visible_to_selector,
279};
280pub use runtime::{Adapter, AppLike, RunError, RunReport, RunStepReport, StepDebugRecord};
281
282/// A maestro YAML command. Each variant corresponds to one or more
283/// `smix-sdk` action calls; the enum itself is a structural
284/// representation of the parsed yaml node.
285///
286/// References to the yaml schema:
287/// - `tapOn`: `{ "tapOn": "X" }` short or `{ "tapOn": { text, id, index, ... } }` full
288/// - `waitForAnimationToEnd`: `{ "waitForAnimationToEnd": null }`
289/// - `extendedWaitUntil`: `{ "extendedWaitUntil": { visible: { text, id }, timeout } }`
290/// - `assertVisible`: `{ "assertVisible": "X" | { text|id } }`
291/// - `inputText`: `{ "inputText": "..." }`
292/// - `pressKey`: `{ "pressKey": "back" | "enter" | ... }`
293/// - `runFlow`: `{ "runFlow": "path/to/subflow.yaml" }`
294///   or `{ "runFlow": { when: { visible }, file } }`
295/// - `scrollUntilVisible`: `{ "scrollUntilVisible": { element: {...}, direction, ... } }`
296/// - `eraseText`: `{ "eraseText": <n> }`
297/// - `swipe`: `{ "swipe": { from, to, ... } }`
298/// - `launchApp`: `{ "launchApp": { clearState, clearKeychain, appId } }`
299/// - `openLink`: `{ "openLink": "<url>" }`
300/// - `stopApp`: `{ "stopApp": null }`
301///
302/// Explicit tap-dispatch override for `tapOn: { dispatch: … }`.
303///
304/// The default tap path (host-resolve → IOHID native-event synthesize)
305/// fires SwiftUI `onTap` and RN Pressable `onPress` reliably. Two
306/// runtime-specific cases need a different mechanism, and WHICH taps
307/// need it is knowledge only the test author has (per the smix
308/// three-layer model the capability lives in core; the decision is the
309/// caller's):
310///
311/// - `xcui` — `XCUIElement.tap()` anchored dispatch. SwiftUI
312///   `.sheet` / `.alert` / `.confirmationDialog` / `.fullScreenCover`
313///   dismiss BINDINGS don't fire from coord-based taps on iOS 17+;
314///   the element-anchored path is the only one that flips the binding.
315///   Requires an `id:` selector (the runner resolves by identifier).
316/// - `daemonProxy` — XCTRunnerDaemonSession synthesize.
317///   Bypasses the XCUIElement gesture-recognizer chain so RN
318///   `RCTTouchHandler` receives the raw touch; use when a Pressable
319///   swallows the default path on an older RN.
320#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "camelCase")]
322pub enum TapDispatch {
323    /// `XCUIElement.tap()` anchored dispatch (requires `id:` selector).
324    Xcui,
325    /// XCTRunnerDaemonSession touch synthesize.
326    DaemonProxy,
327}
328
329/// One parsed yaml step. See the maestro-compat command table in the
330/// module docs above for the yaml shapes each variant corresponds to.
331#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub enum Step {
334    /// Tap an element by selector. Maps to `App::tap`.
335    TapOn {
336        /// Resolved selector — parser converts the yaml short / full form
337        /// to a [`smix_selector::Selector`].
338        selector: Selector,
339        /// `optional: true` swallows the not-found error per maestro semantics.
340        #[serde(default)]
341        optional: bool,
342        /// Explicit dispatch-mechanism override
343        /// (`dispatch: xcui | daemonProxy`). `None` = default routing.
344        #[serde(default, skip_serializing_if = "Option::is_none")]
345        dispatch: Option<TapDispatch>,
346    },
347    /// Tap at a normalized coordinate point (escape hatch for yaml
348    /// `tapOn: { point: "X%,Y%" }`). Maps to `App::tap_at_coord(nx,
349    /// ny)`: `nx`/`ny` ∈ \[0.0, 1.0\].
350    TapAtPoint {
351        /// Normalized X (0.0 = left, 1.0 = right).
352        nx: f64,
353        /// Normalized Y (0.0 = top, 1.0 = bottom).
354        ny: f64,
355    },
356    /// Eval JS against the fixture-side WKWebView bridge. yaml shape:
357    ///   `- webview_eval: "<js expression>"`  (short form)
358    ///   `- webview_eval: { js: "...", assert_eq: <expected JSON value> }` (full form)
359    /// Adapter dispatches to `App::webview_eval(js)`; on `assert_eq`
360    /// presence, compares result and raises NotMatching on mismatch.
361    WebViewEval {
362        /// JS expression to evaluate (`document.querySelector(...)...` etc).
363        js: String,
364        /// Optional expected JSON value. If set + matches, step OK; if
365        /// set + mismatches, step fails with assertion error. None = just
366        /// eval and discard.
367        assert_eq: Option<serde_json::Value>,
368    },
369    /// Wait for the screen to stop moving.
370    ///
371    /// Returns as soon as two sampled frames are still, so a flow pays
372    /// only for the animation that actually ran. The number is a
373    /// ceiling:
374    /// - `- waitForAnimationToEnd` — up to 400 ms (maestro's default)
375    /// - `- waitForAnimationToEnd: 500` — up to 500 ms
376    /// - `- waitForAnimationToEnd: { timeout: 5000 }` — maestro's form
377    ///
378    /// Reaching the ceiling is not a failure: the step warns and moves
379    /// on, because a screen that never settles is usually a spinner the
380    /// flow does not care about.
381    ///
382    /// **NOT** an XCTest idle-wait — `SmixQuiescenceSwizzle.m` no-ops
383    /// XCTest's idle wait for performance (RN long-running animations
384    /// would otherwise stall every operation), and this verb never went
385    /// through it anyway. Stillness here is measured by comparing
386    /// screenshots.
387    WaitForAnimationToEnd {
388        /// How long to wait for the screen to stop moving, in
389        /// milliseconds — a ceiling, not a duration. The step returns as
390        /// soon as the screen is still.
391        ///
392        /// All three yaml forms land here: bare
393        /// (`- waitForAnimationToEnd`) → 400 ms, maestro's default;
394        /// numeric (`- waitForAnimationToEnd: 500`); and maestro's
395        /// mapping form (`- waitForAnimationToEnd: { timeout: 5000 }`).
396        ceiling_ms: u64,
397    },
398    /// Extended wait until a selector matches the expected visibility.
399    /// `expect_visible=true` waits for visible (maestro yaml `visible:` arm);
400    /// `expect_visible=false` waits for not visible (maestro yaml
401    /// `notVisible:` arm). Which arm applies is decided at parse time,
402    /// so the runtime has no branch.
403    ExtendedWaitUntil {
404        /// Selector that must match the expected visibility state.
405        selector: Selector,
406        /// Timeout in milliseconds.
407        timeout_ms: u64,
408        /// `true` ⇒ wait_for (visible); `false` ⇒ wait_for_not_visible.
409        #[serde(default = "default_expect_visible")]
410        expect_visible: bool,
411    },
412    /// Assert a selector is currently visible on screen. Maps to a
413    /// non-waiting `App::find` + raise on miss.
414    AssertVisible {
415        /// Selector to verify.
416        selector: Selector,
417    },
418    /// Type literal text into the focused field. Maps to `App::fill` (chunked).
419    InputText(String),
420    /// Type into a specific field: yaml `inputText: { id, text }`.
421    /// Maps to `App::fill(selector, text)` — targeted where
422    /// [`Step::InputText`] types into whatever holds focus.
423    InputTextInto {
424        /// The field to fill.
425        selector: Selector,
426        /// What to type.
427        text: String,
428    },
429    /// Press a hardware / IME key. Maps to `App::press_key`.
430    PressKey(String),
431    /// Navigation back (maestro `back`): iOS navbar-back / edge swipe,
432    /// Android KEYCODE_BACK. Not a keyboard key.
433    Back,
434    /// Recursively run a referenced yaml. The parser keeps the raw
435    /// (potentially relative) path string here; [`parse_flow_file`]
436    /// resolves it against the invoking yaml's directory and expands
437    /// unconditional invocations inline. Conditional invocations stay
438    /// as [`Step::RunFlowConditional`] (runtime evaluation belongs to
439    /// the adapter `Adapter::run`, not the parser).
440    RunFlow(String),
441    /// Conditional `runFlow: { when: { visible }, file, as }`. Parser stores
442    /// the path verbatim, the gating selector, and the optional outputs
443    /// alias name; `Adapter::run` checks visibility before invoking the
444    /// inner flow and captures the outputs alias.
445    RunFlowConditional {
446        /// Raw path string from the yaml (relative to invoking file).
447        file: String,
448        /// Visibility precondition (`when.visible`). `None` would be a
449        /// degenerate yaml — current parser still accepts it for
450        /// forward-compat, mapping `None` to "run unconditionally".
451        #[serde(skip_serializing_if = "Option::is_none")]
452        when_visible: Option<Selector>,
453        /// Inverse gate (`when.notVisible`). Runs the
454        /// subflow only when the selector is NOT visible. Enables the
455        /// idempotency pattern "only enter conditional if the target
456        /// state hasn't already been reached" (e.g. `notVisible:
457        /// 'qa-bubble'` = enter the gate ceremony only if not already
458        /// past it). Exactly one of `when_visible` / `when_not_visible`
459        /// should be Some in idiomatic yaml; both Some is a parse
460        /// error, both None is unconditional (same as legacy).
461        #[serde(default, skip_serializing_if = "Option::is_none")]
462        when_not_visible: Option<Selector>,
463        /// Outputs alias: after the subflow runs, read the device
464        /// pasteboard (canonical "what the subflow captured via
465        /// copyTextFrom") and write it into the parent flow's output map
466        /// under this name. None ⇒ no capture (legacy semantics). Mirrors
467        /// maestro yaml `runFlow: { file, as: <name> }` capture form.
468        #[serde(default, skip_serializing_if = "Option::is_none")]
469        as_name: Option<String>,
470    },
471    /// maestro `runFlow: { when: { visible }, commands: [...] }`
472    /// inline form. The body is a literal list of steps held in-place; no
473    /// child yaml file is referenced. `when.visible` gates execution
474    /// identically to [`Step::RunFlowConditional`]. Mirrors maestro's
475    /// `YamlRunFlow` `commands:` alternative to `file:` (used widely for
476    /// short conditional helpers that don't justify a separate file —
477    /// e.g. `dismiss-open-in` style SpringBoard popup dismissers).
478    /// `file` and `commands` are mutually exclusive at parse time.
479    RunFlowInline {
480        /// Visibility precondition (`when.visible`). `None` ⇒ unconditional.
481        #[serde(skip_serializing_if = "Option::is_none")]
482        when_visible: Option<Selector>,
483        /// Inverse gate (`when.notVisible`). See
484        /// [`Step::RunFlowConditional::when_not_visible`] for semantics.
485        #[serde(default, skip_serializing_if = "Option::is_none")]
486        when_not_visible: Option<Selector>,
487        /// Inline step list. Runtime executes top-to-bottom under the
488        /// same warning channel as the parent flow.
489        steps: Vec<Step>,
490    },
491    /// Scroll the screen until a selector becomes visible.
492    /// Maps to a `swipe` loop + `find` polling.
493    ScrollUntilVisible {
494        /// Selector to find.
495        selector: Selector,
496        /// Direction (`up` / `down` / `left` / `right`).
497        direction: String,
498    },
499    /// Erase N characters from the focused field. Maps to `App::press_key` × N delete.
500    EraseText(u32),
501    /// Swipe between two points. Maps to `App::swipe`.
502    Swipe {
503        /// Start point (normalized coords).
504        from: (f64, f64),
505        /// End point (normalized coords).
506        to: (f64, f64),
507    },
508    /// Launch / relaunch the app under test. Supports the full maestro
509    /// yaml sub-parameter set: appId / clearState / clearKeychain /
510    /// permissions / arguments / stopApp.
511    /// `stopApp=true` (default) → terminate + (optional wipe) + launch_with_args;
512    /// `stopApp=false` → foreground (resume already-running app).
513    LaunchApp {
514        /// Bundle id to launch.
515        app_id: String,
516        /// Whether to wipe MMKV / NSUserDefaults before launching.
517        #[serde(default)]
518        clear_state: bool,
519        /// Whether to wipe the Keychain before launching.
520        #[serde(default)]
521        clear_keychain: bool,
522        /// maestro yaml `permissions: { name: allow|deny|unset }`.
523        /// Empty = no permission changes. Applied in mapping iteration order
524        /// before launch.
525        #[serde(default)]
526        permissions: Vec<(String, MaestroPermissionAction)>,
527        /// maestro yaml `arguments: [...]` — process-level argv.
528        #[serde(default)]
529        arguments: Vec<String>,
530        /// maestro yaml `stopApp: bool` (default true).
531        #[serde(default = "default_stop_app")]
532        stop_app: bool,
533        /// maestro yaml `waitForInteractiveMs: <ms>`. When set (and
534        /// stopApp is true — so we go through the cooperative launch
535        /// pathway), threads through to
536        /// `SessionAppLifecycleRequest.wait_for_interactive_ms` on the
537        /// runner. The runner polls the a11y tree at 500 ms cadence
538        /// and re-snapshots each iteration: without the refresh, a
539        /// Fabric mount-item drain that lands mid-poll is read off a
540        /// stale snapshot and the tree looks empty. `None` = dispatch
541        /// and return as soon as the app reaches foreground.
542        #[serde(default)]
543        wait_for_interactive_ms: Option<u64>,
544    },
545    /// Clear the current session's app data IN PLACE without
546    /// launching. Maps to [`smix_sdk::App::clear_app_data`] (the
547    /// `_with_launch_options` variant when the yaml supplies args or
548    /// env). Pairs with an optional trailing `launchApp: {}` when the
549    /// caller wants a fresh instance.
550    ///
551    /// Replaces `launchApp: { clearState: true }` — the old shape now
552    /// emits a deprecation WARN and internally expands to
553    /// `ClearAppData + LaunchApp`. Consumers who migrate see the
554    /// deprecation notice go away and get the crash-dialog fix
555    /// automatically.
556    ///
557    /// Accepts optional `launchArgs` / `launchEnv` to steer
558    /// scaffolding shown at cold launch: the Expo dev-launcher server
559    /// picker on SDK 57 stopped auto-navigating on a URL scheme, so
560    /// launch args are how the metro target reaches it. Bare
561    /// `- clearAppData` remains valid and equivalent to empty vec +
562    /// empty map.
563    ClearAppData {
564        /// launchArguments forwarded to the cooperative runner-side
565        /// launch. Empty vec = no arguments.
566        launch_args: Vec<String>,
567        /// launchEnvironment forwarded to the cooperative runner-side
568        /// launch. Empty map = no environment overrides.
569        launch_env: std::collections::BTreeMap<String, String>,
570    },
571    /// URL-scheme-driven app-owned reset.
572    /// Distinct from [`Step::ClearAppData`] (which wipes the whole
573    /// container including dev-fixture state). resetAppData fires the
574    /// supplied URL via `simctl openurl`, then optionally tails the
575    /// external metro log for a completion pattern before returning.
576    ///
577    /// Consumer app is responsible for handling the URL and emitting
578    /// the completion signal (e.g., `console.log('[dev]
579    /// reset-complete token=...')`). This keeps smix agnostic to
580    /// what "reset" means for the app; the app decides.
581    ///
582    /// yaml shapes both accepted:
583    /// ```yaml
584    /// - resetAppData: 'myapp://dev-mutate?action=reset'
585    ///
586    /// - resetAppData:
587    ///     via: url-scheme
588    ///     url: 'myapp://dev-mutate?action=reset'
589    ///     waitFor:
590    ///       logLinePattern: '\[myapp-dev\] reset-complete token='
591    ///       timeoutMs: 5000
592    /// ```
593    ResetAppData {
594        /// URL scheme string (`simctl openurl <UDID> <url>`).
595        url: String,
596        /// Completion-signal wait strategy. `None` = fire URL and
597        /// return immediately (no wait). See [`ResetAppDataWaitFor`]
598        /// for supported variants.
599        wait_for: Option<ResetAppDataWaitFor>,
600        /// Timeout in ms for the wait_for path. Ignored when
601        /// `wait_for` is None. Default 5000 at the parser layer.
602        timeout_ms: u64,
603    },
604    /// Delete keys from the target app's persisted
605    /// user-defaults store (iOS NSUserDefaults via `simctl spawn
606    /// defaults delete`; Android unsupported). yaml shape:
607    /// `clearUserDefaults: { keys: [k1, k2], bundleId?: <id> }`.
608    /// `bundleId` absent ⇒ the flow's resolved app id. Contract is
609    /// "ensure keys absent" — already-absent keys succeed. Terminate
610    /// the app first (running processes cache defaults in-memory).
611    ///
612    /// Motivating case: neutralizing expo-dev-launcher's persisted
613    /// deep-link replay between terminate and relaunch.
614    ClearUserDefaults {
615        /// NSUserDefaults keys to delete.
616        keys: Vec<String>,
617        /// Target defaults domain. `None` ⇒ flow's resolved app id.
618        #[serde(default, skip_serializing_if = "Option::is_none")]
619        bundle_id: Option<String>,
620    },
621    /// Open a URL / deep link in the OS handler. Maps to
622    /// `simctl openurl`.
623    OpenLink(String),
624    /// Terminate the foreground app. Maps to `simctl terminate`.
625    StopApp,
626    /// Viewport scroll one swipe (default direction = down).
627    /// maestro `scroll:` (bare, no args). Maps to `App::scroll_screen`.
628    Scroll,
629    /// Dismiss the on-screen keyboard. maestro `hideKeyboard`.
630    /// Maps to `App::hide_keyboard`.
631    HideKeyboard,
632    /// Assert selector is NOT visible. maestro `assertNotVisible`.
633    /// Maps to `App::assert_not_visible`.
634    AssertNotVisible {
635        /// Selector that must NOT be visible.
636        selector: Selector,
637    },
638    /// Terminate an explicitly-specified app by bundle id.
639    /// maestro `killApp: "com.x"` (independent from launchApp). Maps to
640    /// `App::terminate`.
641    KillApp {
642        /// Bundle id to terminate; `None` (bare `- killApp`) means the
643        /// current app, resolved from the last launched bundle.
644        app_id: Option<String>,
645    },
646    /// Wipe an app's MMKV / NSUserDefaults / file storage
647    /// independent of launchApp. maestro `clearState: { appId }`.
648    /// Maps to `App::launch_fresh(_, clear_state: true, clear_keychain: false, _)`.
649    ClearState {
650        /// Bundle id to wipe; `None` (bare `- clearState`) means the
651        /// current app, resolved from the last launched bundle.
652        app_id: Option<String>,
653    },
654    /// Wipe the Keychain entries for the last launched app
655    /// (or top-of-flow appId). maestro `clearKeychain` (bare, no args).
656    /// Maps to `App::launch_fresh(_, false, true, _)` with last_bundle.
657    ClearKeychain,
658    /// Capture a screenshot with optional
659    /// annotations. maestro `takeScreenshot: "name"` (string) or bare
660    /// `- takeScreenshot` (None). smix-native long form:
661    /// ```yaml
662    /// - takeScreenshot:
663    ///     name: hub-form
664    ///     annotate:
665    ///       - circle: { at: { id: submit }, color: red, radius: 40 }
666    ///       - text: { at: { x: 20, y: 20 }, content: "step 1", color: green, size: 20 }
667    ///       - arrow: { from: { x: 100, y: 100 }, to: { x: 200, y: 200 }, color: blue }
668    /// ```
669    TakeScreenshot {
670        /// Optional output file name relative to cwd. None = discard bytes.
671        #[serde(skip_serializing_if = "Option::is_none")]
672        path: Option<String>,
673        /// Annotations composed onto the PNG before write. Empty =
674        /// plain screenshot. Selector-relative positions resolve
675        /// against the a11y tree fetched at capture time.
676        #[serde(default, skip_serializing_if = "Vec::is_empty")]
677        annotations: Vec<AnnotationSpec>,
678    },
679    /// Write a literal to the device pasteboard. maestro yaml
680    /// `setClipboard: "literal"`.
681    SetClipboard(String),
682    /// Paste into focused field. `text: Some(literal)` =
683    /// `pasteText: "literal"` (writes clipboard then fills);
684    /// `text: None` = bare `- pasteText` (reads current clipboard then fills).
685    PasteText {
686        /// `None` = read clipboard, `Some(s)` = set clipboard + fill `s`.
687        #[serde(skip_serializing_if = "Option::is_none")]
688        text: Option<String>,
689    },
690    /// Copy element text content into the device pasteboard.
691    /// maestro yaml `copyTextFrom: <selector>`. Field priority value→text→label.
692    CopyTextFrom {
693        /// Selector picking the text-bearing element.
694        selector: Selector,
695    },
696    /// Double-tap on element. maestro `doubleTapOn: <selector>`.
697    /// XCUIElement.doubleTap() public API path.
698    DoubleTapOn {
699        /// Selector picking the tap target.
700        selector: Selector,
701    },
702    /// Tap one element several times, spaced on the event timeline.
703    ///
704    /// `repeat` around `tapOn` sends a request per tap; at ~400 ms per
705    /// synthesise that cannot drive a gesture gated on a short
706    /// inter-tap window, and the spacing is whatever the round trip
707    /// cost rather than a number the flow states.
708    RepeatTap {
709        /// Selector picking the tap target.
710        selector: Selector,
711        /// How many touches.
712        times: u32,
713        /// Milliseconds between touches; `None` takes the runner default.
714        interval_ms: Option<u32>,
715        /// Milliseconds each touch is held; `None` takes the default.
716        hold_ms: Option<u32>,
717    },
718    /// Long-press on element with optional duration (ms,
719    /// default 500). maestro `longPressOn: <selector>` (scalar) or
720    /// `longPressOn: { ..., duration: N }`. XCUIElement.press(forDuration:).
721    LongPressOn {
722        /// Selector picking the press target.
723        selector: Selector,
724        /// Press duration in milliseconds (default 500 per maestro doc).
725        duration_ms: u64,
726        /// Capture frames of the held state while the touch is down.
727        capture_during: bool,
728    },
729    /// Assert a yaml expression evaluates truthy. maestro
730    /// `assertTrue: ${expression}`. The expression source is held raw
731    /// (either `${...}`-wrapped or a bare literal); the runtime
732    /// applies expand_template and the expression engine together.
733    /// Unsupported patterns raise an explicit `DriverError` rather
734    /// than silently passing.
735    AssertTrue {
736        /// Raw expression source, verbatim.
737        expr: String,
738    },
739    /// Looped subflow. maestro yaml `repeat: { ... }`.
740    /// `RepeatMode::While { condition_expr }` evaluates the expression
741    /// truthy before each iteration; `RepeatMode::Times(N)` runs fixed
742    /// N iterations. `repeat.while: { visible: <selector> }` maps to
743    /// [`RepeatMode::WhileVisible`].
744    Repeat {
745        /// Loop mode. Chosen at parse time, so the runtime has no branch.
746        mode: RepeatMode,
747        /// Body to run per iteration (recursively parsed).
748        commands: Vec<Step>,
749    },
750    /// Retry body on failure up to `max_retries` extra attempts.
751    /// Initial + max_retries = max attempts total. Last attempt's
752    /// `RunError` propagates if all attempts fail.
753    Retry {
754        /// Extra retry attempts on top of initial run.
755        max_retries: u32,
756        /// Body to attempt (recursively parsed).
757        commands: Vec<Step>,
758    },
759    /// maestro `runScript: <source>` (inline literal or file path
760    /// verbatim). The parser accepts it so yaml files stay portable
761    /// with maestro, but there is no JS runtime behind it: the adapter
762    /// runtime raises an explicit `DriverError` rather than silently
763    /// treating the script as a no-op.
764    RunScript {
765        /// Raw script source (inline literal or file path verbatim).
766        source: String,
767    },
768    /// maestro `evalScript: <expr>`. Same graceful unsupported
769    /// semantics as [`Step::RunScript`].
770    EvalScript {
771        /// Raw expression source.
772        source: String,
773    },
774    /// maestro `setLocation: { latitude, longitude }`.
775    /// Walks `App::set_location` → simctl location set.
776    SetLocation {
777        /// Latitude in decimal degrees.
778        latitude: f64,
779        /// Longitude in decimal degrees.
780        longitude: f64,
781    },
782    /// maestro `travel: { points: [...], speed_mps?: <m/s> }`.
783    /// Fire-and-return — downstream Step does not block on playback.
784    Travel {
785        /// Ordered waypoints (lat, lng). ≥2 required (enforced by parser).
786        points: Vec<(f64, f64)>,
787        /// Optional travel speed in m/s (forwarded to `simctl location
788        /// start --speed=...`).
789        speed_mps: Option<f64>,
790    },
791    /// maestro `setPermissions: { camera: allow, ... }` (top-level
792    /// command, distinct from the `launchApp.permissions`
793    /// sub-parameter). `app_id` is None at parse time — runtime resolves from
794    /// `last_bundle`; empty `last_bundle` → explicit DriverError.
795    SetPermissions {
796        /// Bundle id; `None` ⇒ runtime resolves from `last_bundle`.
797        #[serde(skip_serializing_if = "Option::is_none")]
798        app_id: Option<String>,
799        /// Permission directives in mapping iteration order.
800        permissions: Vec<(String, MaestroPermissionAction)>,
801    },
802    /// maestro `addMedia: <path>` (scalar) or `addMedia: [paths]`
803    /// (array). Adapter flattens both into `Vec<String>`.
804    AddMedia {
805        /// Absolute or relative paths to media files.
806        paths: Vec<String>,
807    },
808    /// maestro `setOrientation: <variant>`. Walks
809    /// `App::set_orientation` → driver `/set-orientation` route →
810    /// swift `XCUIDevice.shared.orientation`.
811    SetOrientation {
812        /// Orientation literal aligned with maestro yaml.
813        orientation: smix_sdk::MaestroOrientation,
814    },
815    /// maestro `startRecording: <output-path>`. Spawns
816    /// `xcrun simctl io recordVideo` as a long-running child via SDK::App.
817    /// Pair with `stopRecording` for clean SIGINT-and-wait shutdown.
818    StartRecording {
819        /// Output mp4 path (relative or absolute).
820        path: String,
821    },
822    /// maestro `stopRecording` (bare). SIGINT-and-wait the
823    /// child started by previous `startRecording`. No prior start →
824    /// DriverError.
825    StopRecording,
826    /// Visual regression assertion against a baseline PNG. maestro
827    /// `assertScreenshot: "path/to/baseline.png"` (scalar) OR mapping
828    /// form `{ path, threshold?, mask? }`. Baseline path is resolved relative
829    /// to the invoking yaml's base_dir; first run auto-records (writes the
830    /// captured screenshot to baseline_path + warns the adapter RunReport),
831    /// subsequent runs compute a 64-bit dhash on both and assert hamming
832    /// distance ≤ `max_hamming` (None = 5 default). Set env
833    /// `SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD=1` to force strict mode.
834    ///
835    /// The mapping form is **partly surface-only**: `threshold`
836    /// overrides the dhash hamming cap and takes effect; `mask` is
837    /// parsed into [`MaskRegion`] but the runtime emits a warning and
838    /// ignores the regions — region exclusion needs a perceptual hash
839    /// (SSIM/pHash) backbone that the current dhash implementation
840    /// does not provide.
841    AssertScreenshot {
842        /// Baseline PNG path relative to the flow's base_dir.
843        path: String,
844        /// `threshold` field from mapping form, dhash hamming
845        /// max distance override. None ⇒ scalar-form default (5).
846        #[serde(default, skip_serializing_if = "Option::is_none")]
847        max_hamming: Option<u32>,
848        /// `mask` regions (normalized 0..1 bbox) accepted from
849        /// mapping form. Runtime emits warn-and-ignore (surface-only).
850        /// Empty Vec for scalar form.
851        #[serde(default, skip_serializing_if = "Vec::is_empty")]
852        mask: Vec<MaskRegion>,
853    },
854    /// Ask the AI judge whether a plain-language condition holds on the
855    /// current screen.
856    ///
857    /// Unlike every other assert, this one is a judgement rather than a
858    /// measurement: the verdict comes from a local `claude` CLI reading a
859    /// screenshot, and the same screen may not produce the same answer
860    /// twice. Opt-in, and the runtime marks the result as non-deterministic.
861    AssertCondition {
862        /// The condition, in plain language.
863        condition: String,
864    },
865    /// Have the AI judge read structured fields off the screen into the
866    /// output store, for later `assertTrue` expressions.
867    ///
868    /// Non-deterministic, on the same terms as [`Step::AssertCondition`].
869    ExtractWithAI {
870        /// Key the extracted object lands under in `output.*`.
871        into: String,
872        /// Field names to read off the screen.
873        fields: Vec<String>,
874    },
875    /// Await a single log signal matching `regex` (optionally
876    /// constrained by `level`) within `timeout_ms`, over the specified
877    /// `window`.
878    ///
879    /// yaml surface:
880    /// ```yaml
881    /// - expect:
882    ///     signal:
883    ///       regex: "env=qa-mode"
884    ///       timeoutMs: 8000
885    ///       window:
886    ///         sinceStep: 2              # optional; default = SinceRun
887    /// ```
888    ExpectSignal {
889        /// Regex to match against the log line message.
890        regex: String,
891        /// Optional log-level constraint (debug/info/warn/error/log).
892        /// None = any level.
893        #[serde(default, skip_serializing_if = "Option::is_none")]
894        level: Option<String>,
895        /// Timeout in milliseconds.
896        timeout_ms: u64,
897        /// Window shape. `SinceRun` (default), `SinceStep(n)`, or
898        /// `LastMs(ms)`.
899        #[serde(default)]
900        window: SignalWindow,
901    },
902    /// Ordered / unordered multi-signal await. See
903    /// [`Step::ExpectSignal`] for surface details.
904    ///
905    /// ```yaml
906    /// - expect:
907    ///     signals:
908    ///       - regex: "launchOverrideConsumed"
909    ///       - regex: "autoLoginValidated"
910    ///     order: strict            # or "any" (default)
911    ///     timeoutMs: 30000
912    /// ```
913    ExpectSignals {
914        /// Ordered list of signal matchers.
915        signals: Vec<SignalMatch>,
916        /// Ordering semantics: `strict` (in-list order) or `any`.
917        #[serde(default)]
918        order: SignalOrderKind,
919        /// Timeout in milliseconds for all matchers.
920        timeout_ms: u64,
921        /// Window over which to look.
922        #[serde(default)]
923        window: SignalWindow,
924    },
925    /// Post-run log-hygiene assertion. Verifies that
926    /// no log entries outside the configured allowlist have been
927    /// emitted at level >= warn during the flow. Populated by the CLI
928    /// flag `--expect-log-clean` or the yaml verb `expectLogClean: true`.
929    ExpectLogClean,
930    /// Look up `id` in the fixture registry to find
931    /// `{testID, signal, timeoutMs}`, open the QA overlay, tap the
932    /// chip, and await the declared signal.
933    ///
934    /// yaml surface (short + long forms):
935    /// ```yaml
936    /// - fixture: prime-search-history
937    /// - fixture:
938    ///     id: prime-search-history
939    ///     timeoutMs: 12000        # optional override
940    /// ```
941    Fixture {
942        /// Fixture id (looked up in the registry).
943        id: String,
944        /// Optional yaml-side timeout override (in ms). None → uses
945        /// the registry's declared `timeoutMs`.
946        #[serde(default, skip_serializing_if = "Option::is_none")]
947        timeout_ms: Option<u64>,
948    },
949}
950
951/// Sub-field of [`Step::ExpectSignals`].
952#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
953pub struct SignalMatch {
954    /// Regex to match against the log line message.
955    pub regex: String,
956    /// Optional log-level constraint (debug/info/warn/error/log). None = any.
957    #[serde(default, skip_serializing_if = "Option::is_none")]
958    pub level: Option<String>,
959}
960
961/// Completion-signal wait strategy for [`Step::ResetAppData`].
962///
963/// Re-exported from `smix-sdk`, where the canonical definition lives
964/// because the `App` impl consumes it.
965pub use smix_sdk::ResetAppDataWaitFor;
966
967/// Sliding-window spec for `expect.signal` / `expect.signals` verbs.
968/// Selects the segment of the metro log tail that a signal search
969/// scans against. Mirrors `smix_metro_log::Window` at the yaml level;
970/// the runtime translates `SinceStep` to `Window::SinceMs` using the
971/// tail's ms cursor captured at each step-end.
972#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
973#[serde(rename_all = "camelCase")]
974pub enum SignalWindow {
975    /// Everything since the tail was constructed (runner boot).
976    SinceRun,
977    /// After step N ended.
978    SinceStep {
979        /// 1-indexed step number.
980        since_step: usize,
981    },
982    /// Trailing N ms window.
983    LastMs {
984        /// Milliseconds prior to now.
985        last_ms: u64,
986    },
987}
988
989#[allow(clippy::derivable_impls)]
990impl Default for SignalWindow {
991    fn default() -> Self {
992        SignalWindow::SinceRun
993    }
994}
995
996/// Ordering semantics for [`Step::ExpectSignals`].
997#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
998#[serde(rename_all = "lowercase")]
999pub enum SignalOrderKind {
1000    /// Every signal must be matched at some point in the window;
1001    /// order in the yaml is documentation-only.
1002    Any,
1003    /// Signals must appear in the exact listed sequence.
1004    Strict,
1005}
1006
1007#[allow(clippy::derivable_impls)]
1008impl Default for SignalOrderKind {
1009    fn default() -> Self {
1010        SignalOrderKind::Any
1011    }
1012}
1013
1014/// Normalized bbox for `assertScreenshot.mask` regions. Coordinates
1015/// are 0..1 fractions of the captured screenshot — the same
1016/// convention maestro's mapping form uses. Surface-only: the adapter
1017/// parses and carries the regions, but the runtime emits a warning
1018/// and skips region exclusion, which needs a perceptual hash backbone
1019/// the current dhash implementation does not provide.
1020#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1021pub struct MaskRegion {
1022    /// Top-left x in 0..1 fraction of capture width.
1023    pub x: f64,
1024    /// Top-left y in 0..1 fraction of capture height.
1025    pub y: f64,
1026    /// Region width in 0..1 fraction of capture width.
1027    pub width: f64,
1028    /// Region height in 0..1 fraction of capture height.
1029    pub height: f64,
1030}
1031
1032/// `Step::Repeat` mode. The parser resolves the mode (yaml
1033/// `repeat.times` xor `repeat.while`), so the runtime has no branch.
1034#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1035#[serde(rename_all = "camelCase", tag = "mode", content = "value")]
1036pub enum RepeatMode {
1037    /// `repeat: { while: "<expr>", commands: [...] }` — evaluate expression
1038    /// truthy each iteration; loop exits on falsy. Bounded by
1039    /// `MAX_REPEAT_ITERATIONS` runtime safety valve.
1040    While {
1041        /// Expression source (raw; `${...}` wrapping handled).
1042        condition_expr: String,
1043    },
1044    /// `repeat: { while: { visible: <selector> }, commands: [...] }`
1045    /// mapping form. Loop continues while the selector resolves to a
1046    /// visible element; exits when not visible. Bounded by
1047    /// `MAX_REPEAT_ITERATIONS` runtime safety valve.
1048    WhileVisible {
1049        /// Element selector evaluated each iteration via App::find_first.
1050        selector: smix_sdk::Selector,
1051    },
1052    /// `repeat: { while: { notVisible: <selector> }, commands: [...] }`
1053    /// mapping form. Loop continues while the selector does NOT resolve
1054    /// to a visible element; exits when the element appears. Bounded by
1055    /// `MAX_REPEAT_ITERATIONS` runtime safety valve. Useful for "wait
1056    /// until X is loaded" patterns where the body keeps triggering until
1057    /// the watched element shows up.
1058    WhileNotVisible {
1059        /// Element selector evaluated each iteration via App::find.
1060        selector: smix_sdk::Selector,
1061    },
1062    /// `repeat: { times: N, commands: [...] }` — fixed N iterations.
1063    Times(u32),
1064}
1065
1066/// serde default for [`Step::ExtendedWaitUntil::expect_visible`] —
1067/// a yaml shape with neither arm named means the `visible:` arm.
1068fn default_expect_visible() -> bool {
1069    true
1070}
1071
1072/// serde default for [`Step::LaunchApp::stop_app`] — maestro yaml
1073/// defaults `stopApp` to true (kill + launch path).
1074fn default_stop_app() -> bool {
1075    true
1076}
1077
1078/// maestro yaml `permissions:` action literal.
1079/// `Allow` → simctl `grant`, `Deny` → simctl `revoke`, `Unset` → simctl `reset`.
1080#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1081#[serde(rename_all = "camelCase")]
1082pub enum MaestroPermissionAction {
1083    /// maestro yaml `"allow"` — grant the permission.
1084    Allow,
1085    /// maestro yaml `"deny"` — revoke the permission.
1086    Deny,
1087    /// maestro yaml `"unset"` — reset the permission to "not determined".
1088    Unset,
1089}
1090
1091impl MaestroPermissionAction {
1092    /// Translate to the SDK's typed enum (1:1 forward).
1093    pub fn to_sdk(self) -> smix_sdk::PermissionAction {
1094        match self {
1095            Self::Allow => smix_sdk::PermissionAction::Grant,
1096            Self::Deny => smix_sdk::PermissionAction::Revoke,
1097            Self::Unset => smix_sdk::PermissionAction::Reset,
1098        }
1099    }
1100}
1101
1102/// A parsed maestro YAML flow — a sequence of [`Step`]s plus an `appId`
1103/// header.
1104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1105#[serde(rename_all = "camelCase")]
1106pub struct Flow {
1107    /// `appId:` top-level key from the yaml header. iOS literal bundle id
1108    /// or Android package name. Empty when only [`Self::app`] (logical
1109    /// cross-platform key) was provided — caller resolves via
1110    /// [`apps_config::AppsConfig`] before dispatch.
1111    pub app_id: String,
1112    /// `app:` top-level yaml key (cross-platform logical name, e.g.
1113    /// `app: demoApp`). Resolved at runtime via `smix-apps.yaml` → the
1114    /// platform-specific bundle id. None when the yaml only uses the
1115    /// legacy `appId:` literal.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub app: Option<String>,
1118    /// Android launch activity, resolved from `smix-apps.yaml`.
1119    ///
1120    /// Not a yaml key: there is nowhere in a flow to write it, because
1121    /// it is a property of the app rather than of the run. It arrives
1122    /// here from [`apps_config::resolve_app_into_flow`] and travels on
1123    /// to the launch options, which is the whole journey it never used
1124    /// to make — it was read, defaulted, and dropped.
1125    #[serde(default, skip_serializing_if = "Option::is_none")]
1126    pub launch_activity: Option<String>,
1127    /// Ordered step list.
1128    pub steps: Vec<Step>,
1129}
1130
1131/// Adapter parse / dispatch errors.
1132#[derive(Debug, thiserror::Error)]
1133pub enum ParseError {
1134    /// YAML deserialization failed.
1135    #[error("yaml deserialize: {0}")]
1136    Yaml(#[from] serde_norway::Error),
1137    /// I/O failure while reading a referenced flow file.
1138    #[error("io: {0}")]
1139    Io(String),
1140    /// Required yaml field missing.
1141    #[error("missing required field: {0}")]
1142    MissingField(String),
1143    /// Invalid yaml value shape.
1144    #[error("invalid {field}: {reason}")]
1145    InvalidValue {
1146        /// Field path or command name where the failure occurred.
1147        field: String,
1148        /// Human-readable reason.
1149        reason: String,
1150    },
1151    /// Unsupported command (not in the supported maestro yaml subset).
1152    #[error("unsupported command: {0}")]
1153    UnsupportedCommand(String),
1154    /// runFlow cycle detected. `path` is the absolute path that closes
1155    /// the cycle; `stack` is the full traversal stack (root → ... → path).
1156    #[error("runFlow cycle detected at {path:?} (stack: {stack:?})")]
1157    RunFlowCycle {
1158        /// Absolute path of the file that re-entered the in-progress stack.
1159        path: PathBuf,
1160        /// Full traversal stack including the re-entered file at the end.
1161        stack: Vec<PathBuf>,
1162    },
1163}