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}