smix_verbs/lib.rs
1//! smix-verbs — canonical verb table.
2//!
3//! # Reviewer invariant
4//!
5//! Any new yaml verb supported by smix MUST land here first as a
6//! [`VerbEntry`] in [`VERB_TABLE`]. Both `smix-adapter-maestro` parser
7//! and `smix-migrate` codemod depend on this crate; adding an entry
8//! automatically makes the parser accept both canonical forms and the
9//! codemod handle the rename.
10//!
11//! # Design
12//!
13//! - **Pure data crate** — no runtime state, no async, no I/O.
14//! - **Static table** — `&'static [VerbEntry]` for zero-alloc lookup.
15//! - **Two names per entry** — `maestro_name` (the yaml surface maestro
16//! test authors know) and `smix_name` (the smix-canonical form). If
17//! they're identical (e.g. `runFlow`), the entry still appears with
18//! both fields set to the same string.
19//! - **Category** — grouping for reviewer sanity checks + future
20//! auto-generated cross-platform parity tables.
21//! - **Arg shape** — the verb's *primary* yaml shape, and only that.
22//! Informational, reserved for a future codemod arg-transform lookup.
23//!
24//! # What this table does and does not guarantee
25//!
26//! Membership is enforced: a test in `smix-adapter-maestro` fails if the
27//! parser dispatches a verb with no [`VerbEntry`], so the table cannot
28//! silently fall behind the parser again.
29//!
30//! `arg_shape` is **not** enforced, and cannot be. Many verbs accept more
31//! than one shape — `waitForAnimationToEnd` takes bare, numeric, and
32//! `{ timeout }` — while [`ArgShape`] holds a single value. Teaching it
33//! unions would mean encoding the yaml grammar in an enum that exists to
34//! label rows. So it names the shape a reader should expect first, and the
35//! parser remains the authority on what actually parses.
36
37#![forbid(unsafe_code)]
38
39/// Keys a selector mapping may carry in yaml.
40///
41/// The parser refuses anything else, and `smix migrate` warns about it
42/// before a user discovers the refusal at run time — so the two must
43/// read one list. They did not: the parser owned it privately, migrate
44/// knew only about verbs, and a flow with `enabled:` migrated cleanly,
45/// exited 0, and then failed to parse.
46///
47/// Covers the selector discriminators, the spatial modifiers, and the
48/// per-verb options that ride in the same mapping.
49pub const SELECTOR_KEYS: &[&str] = &[
50 // discriminators
51 "text",
52 "id",
53 "label",
54 "role",
55 "name",
56 "point",
57 "localized_text",
58 "localizedText",
59 "ocrText",
60 "anchored",
61 "anchorRelative",
62 "fallback",
63 "focused",
64 // spatial + positional modifiers
65 "near",
66 "below",
67 "above",
68 "leftOf",
69 "rightOf",
70 "inside",
71 "ancestor",
72 "nth",
73 "index",
74 "first",
75 "last",
76 // per-verb options sharing the mapping
77 "optional",
78 "dispatch",
79 "pattern",
80 "recognition_level",
81 "locales",
82 "timeout",
83 "requireOnScreen",
84 "duration",
85 // repeatTap
86 "times",
87 "intervalMs",
88 "holdMs",
89 // longPressOn
90 "captureDuring",
91];
92
93/// One verb in the canonical table.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct VerbEntry {
96 /// Maestro yaml surface name (`tapOn`, `assertVisible`, ...).
97 pub maestro_name: &'static str,
98 /// smix-canonical surface name (`tap`, `expect`, ...).
99 pub smix_name: &'static str,
100 /// Grouping category. Used by the future cross-platform parity
101 /// audit table.
102 pub category: VerbCategory,
103 /// Shape of the argument — informational; used by codemod's
104 /// argument transform lookup + parser's shape validation.
105 pub arg_shape: ArgShape,
106}
107
108/// Semantic grouping for a verb.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum VerbCategory {
111 /// Tap-family (tap, tapOn, doubleTap, longPress, tapByCoord).
112 Tap,
113 /// Text-input family (inputText / fill, eraseText / clear,
114 /// pasteText, setClipboard).
115 Input,
116 /// Assertion / expectation (assertVisible / expect,
117 /// assertNotVisible, assertTrue, extendedWaitUntil, expect.signal).
118 Assert,
119 /// Navigation / control flow (runFlow, retry, repeat,
120 /// pressKey, back).
121 ControlFlow,
122 /// App lifecycle (launchApp, stopApp / terminate, killApp,
123 /// clearState / reset, clearKeychain / resetKeychain).
124 Lifecycle,
125 /// Screen / media (takeScreenshot, assertScreenshot,
126 /// startRecording, stopRecording, addMedia).
127 Media,
128 /// Gesture / motion (scroll, scrollUntilVisible, swipe,
129 /// swipeOnce, hideKeyboard).
130 Gesture,
131 /// Device-level control (openLink / openUrl, setLocation,
132 /// travel, setPermissions, setOrientation).
133 Device,
134 /// smix-native extensions (ocrText, anchorRelative, tapById,
135 /// fixture, webviewEval).
136 SmixNative,
137 /// Utility / diagnostic (waitForAnimationToEnd, evalScript,
138 /// copyTextFrom, assertWithAI).
139 Utility,
140}
141
142/// A verb's primary yaml argument shape.
143///
144/// One value per verb, so a verb accepting several shapes is recorded by
145/// its most common one — see the crate header. The parser decides what
146/// actually parses.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum ArgShape {
149 /// No argument (`- back`, `- stopApp`, `- waitForAnimationToEnd`).
150 None,
151 /// Bare string (`- inputText: hello`, `- pressKey: enter`).
152 BareString,
153 /// Selector — short-form string OR long-form mapping
154 /// (`{ text | id | label ... }`).
155 Selector,
156 /// Mapping with domain-specific keys (`launchApp`, `extendedWaitUntil`,
157 /// `assertScreenshot`, `expect.signal`).
158 Mapping,
159 /// Path string (`- runFlow: path/to.yaml`).
160 Path,
161 /// Coordinate pair or normalized 0..1 point
162 /// (`tapOn: { point: "X%,Y%" }`).
163 Coord,
164 /// Integer count (`eraseText: 5`).
165 Integer,
166}
167
168/// Static canonical verb table. Ordering: category-alphabetized
169/// for grep-ability; each row is `(maestro, smix, category, shape)`.
170pub static VERB_TABLE: &[VerbEntry] = &[
171 // ----- Tap family -----
172 v("tapOn", "tap", VerbCategory::Tap, ArgShape::Selector),
173 v(
174 "doubleTapOn",
175 "doubleTap",
176 VerbCategory::Tap,
177 ArgShape::Selector,
178 ),
179 v(
180 "longPressOn",
181 "longPress",
182 VerbCategory::Tap,
183 ArgShape::Selector,
184 ),
185 // No maestro counterpart: maestro drives repeated taps with
186 // `repeat`, which sends one request per tap. That is the thing this
187 // exists to stop doing.
188 v(
189 "repeatTap",
190 "repeatTap",
191 VerbCategory::Tap,
192 ArgShape::Mapping,
193 ),
194 // ----- Input family -----
195 v(
196 "inputText",
197 "fill",
198 VerbCategory::Input,
199 ArgShape::BareString,
200 ),
201 v("eraseText", "clear", VerbCategory::Input, ArgShape::Integer),
202 v(
203 "pasteText",
204 "pasteText",
205 VerbCategory::Input,
206 ArgShape::None,
207 ),
208 v(
209 "setClipboard",
210 "setClipboard",
211 VerbCategory::Input,
212 ArgShape::BareString,
213 ),
214 v(
215 "copyTextFrom",
216 "copyTextFrom",
217 VerbCategory::Input,
218 ArgShape::Selector,
219 ),
220 // ----- Assert family -----
221 v(
222 "assertVisible",
223 "expect",
224 VerbCategory::Assert,
225 ArgShape::Selector,
226 ),
227 v(
228 "assertNotVisible",
229 "expectNotVisible",
230 VerbCategory::Assert,
231 ArgShape::Selector,
232 ),
233 v(
234 "extendedWaitUntil",
235 "expect",
236 VerbCategory::Assert,
237 ArgShape::Mapping,
238 ),
239 v(
240 "assertTrue",
241 "assertTrue",
242 VerbCategory::Assert,
243 ArgShape::BareString,
244 ),
245 v(
246 "assertScreenshot",
247 "assertScreenshot",
248 VerbCategory::Assert,
249 ArgShape::Mapping,
250 ),
251 // The AI-assertion tier. Opt-in and non-deterministic: a verdict comes
252 // from a local `claude` CLI judging a screenshot, so these two are the
253 // only verbs in the table whose result is a judgement rather than a
254 // measurement.
255 v(
256 "assertWithAI",
257 "assertCondition",
258 VerbCategory::Assert,
259 ArgShape::BareString,
260 ),
261 v(
262 "extractTextWithAI",
263 "extractWithAI",
264 VerbCategory::Assert,
265 ArgShape::Mapping,
266 ),
267 // ----- Control flow -----
268 v(
269 "runFlow",
270 "runFlow",
271 VerbCategory::ControlFlow,
272 ArgShape::Path,
273 ),
274 v(
275 "retry",
276 "retry",
277 VerbCategory::ControlFlow,
278 ArgShape::Mapping,
279 ),
280 v(
281 "repeat",
282 "repeat",
283 VerbCategory::ControlFlow,
284 ArgShape::Mapping,
285 ),
286 v(
287 "pressKey",
288 "pressKey",
289 VerbCategory::ControlFlow,
290 ArgShape::BareString,
291 ),
292 v(
293 "back",
294 "pressKey",
295 VerbCategory::ControlFlow,
296 ArgShape::None,
297 ),
298 // ----- Lifecycle -----
299 v(
300 "launchApp",
301 "launchApp",
302 VerbCategory::Lifecycle,
303 ArgShape::Mapping,
304 ),
305 v(
306 "stopApp",
307 "terminate",
308 VerbCategory::Lifecycle,
309 ArgShape::None,
310 ),
311 v(
312 "killApp",
313 "terminate",
314 VerbCategory::Lifecycle,
315 ArgShape::BareString,
316 ),
317 v(
318 "clearState",
319 "reset",
320 VerbCategory::Lifecycle,
321 ArgShape::None,
322 ),
323 v(
324 "clearKeychain",
325 "resetKeychain",
326 VerbCategory::Lifecycle,
327 ArgShape::None,
328 ),
329 // ----- Media -----
330 v(
331 "takeScreenshot",
332 "takeScreenshot",
333 VerbCategory::Media,
334 ArgShape::BareString,
335 ),
336 v(
337 "startRecording",
338 "startRecording",
339 VerbCategory::Media,
340 ArgShape::BareString,
341 ),
342 v(
343 "stopRecording",
344 "stopRecording",
345 VerbCategory::Media,
346 ArgShape::None,
347 ),
348 v(
349 "addMedia",
350 "addMedia",
351 VerbCategory::Media,
352 ArgShape::Mapping,
353 ),
354 // ----- Gesture -----
355 v("scroll", "scroll", VerbCategory::Gesture, ArgShape::None),
356 v(
357 "scrollUntilVisible",
358 "scroll",
359 VerbCategory::Gesture,
360 ArgShape::Mapping,
361 ),
362 v("swipe", "swipe", VerbCategory::Gesture, ArgShape::Mapping),
363 v(
364 "hideKeyboard",
365 "hideKeyboard",
366 VerbCategory::Gesture,
367 ArgShape::None,
368 ),
369 // ----- Device -----
370 v(
371 "openLink",
372 "openUrl",
373 VerbCategory::Device,
374 ArgShape::BareString,
375 ),
376 v(
377 "setLocation",
378 "setLocation",
379 VerbCategory::Device,
380 ArgShape::Mapping,
381 ),
382 v("travel", "travel", VerbCategory::Device, ArgShape::Mapping),
383 v(
384 "setPermissions",
385 "setPermissions",
386 VerbCategory::Device,
387 ArgShape::Mapping,
388 ),
389 v(
390 "setOrientation",
391 "setOrientation",
392 VerbCategory::Device,
393 ArgShape::BareString,
394 ),
395 // ----- smix-native extensions (parity with parser dispatch_step) -----
396 v(
397 "webview_eval",
398 "webviewEval",
399 VerbCategory::SmixNative,
400 ArgShape::BareString,
401 ),
402 // The spelling three of four guides use — WebView camelized as one
403 // word. Same canonical name; the parser accepts all three.
404 v(
405 "webViewEval",
406 "webviewEval",
407 VerbCategory::SmixNative,
408 ArgShape::BareString,
409 ),
410 v(
411 "webviewEval",
412 "webviewEval",
413 VerbCategory::SmixNative,
414 ArgShape::BareString,
415 ),
416 v(
417 "fixture",
418 "fixture",
419 VerbCategory::SmixNative,
420 ArgShape::Mapping,
421 ),
422 v(
423 "expect",
424 "expect",
425 VerbCategory::SmixNative,
426 ArgShape::Mapping,
427 ),
428 // ----- Utility -----
429 v(
430 "waitForAnimationToEnd",
431 "waitForAnimationToEnd",
432 VerbCategory::Utility,
433 ArgShape::None,
434 ),
435 // ----- Lifecycle / Assert additions: parser-accepted verbs that
436 // were absent from the table. VERB_TABLE is the enforced single
437 // source of truth (a parser-dispatch ⊆ VERB_TABLE test guards it);
438 // these have Step:: variants + parser dispatch but no entry until now.
439 v(
440 "clearAppData",
441 "clearAppData",
442 VerbCategory::Lifecycle,
443 ArgShape::Mapping,
444 ),
445 v(
446 "resetAppData",
447 "resetAppData",
448 VerbCategory::Lifecycle,
449 ArgShape::Mapping,
450 ),
451 v(
452 "clearUserDefaults",
453 "clearUserDefaults",
454 VerbCategory::Lifecycle,
455 ArgShape::Mapping,
456 ),
457 v(
458 "expectLogClean",
459 "expectLogClean",
460 VerbCategory::Assert,
461 ArgShape::None,
462 ),
463 // Note: `evalScript` / `runScript` deliberately NOT in the table —
464 // they're valid maestro verbs but flagged as unknown by migrate to
465 // warn corpus maintainers that porting requires manual review
466 // (typical case: script bodies contain maestro-specific APIs).
467 // Consumers who want them silently preserved can add rows locally.
468];
469
470const fn v(
471 maestro_name: &'static str,
472 smix_name: &'static str,
473 category: VerbCategory,
474 arg_shape: ArgShape,
475) -> VerbEntry {
476 VerbEntry {
477 maestro_name,
478 smix_name,
479 category,
480 arg_shape,
481 }
482}
483
484/// Look up a verb entry by its maestro-canonical name.
485pub fn find_by_maestro(name: &str) -> Option<&'static VerbEntry> {
486 VERB_TABLE.iter().find(|e| e.maestro_name == name)
487}
488
489/// Look up a verb entry by its smix-canonical name. May return
490/// multiple different entries because smix names are not unique
491/// (e.g. `pressKey` is the smix name for both `pressKey` and
492/// `back`); we return the first match. Consumers that need
493/// disambiguation should use [`find_by_maestro`] or scan
494/// [`VERB_TABLE`] directly.
495pub fn find_by_smix(name: &str) -> Option<&'static VerbEntry> {
496 VERB_TABLE.iter().find(|e| e.smix_name == name)
497}
498
499/// True if `name` matches either the maestro or smix form of any
500/// known verb.
501pub fn is_known_verb(name: &str) -> bool {
502 find_by_maestro(name).is_some() || find_by_smix(name).is_some()
503}
504
505/// Return every entry in a given category. Iteration order is the
506/// insertion order in [`VERB_TABLE`].
507pub fn verbs_in_category(category: VerbCategory) -> impl Iterator<Item = &'static VerbEntry> {
508 VERB_TABLE.iter().filter(move |e| e.category == category)
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn find_by_maestro_hits() {
517 let e = find_by_maestro("tapOn").unwrap();
518 assert_eq!(e.smix_name, "tap");
519 assert_eq!(e.category, VerbCategory::Tap);
520 }
521
522 #[test]
523 fn find_by_smix_hits() {
524 let e = find_by_smix("tap").unwrap();
525 assert_eq!(e.maestro_name, "tapOn");
526 }
527
528 #[test]
529 fn find_by_smix_returns_first_when_multiple_match() {
530 // `pressKey` is the smix name for both `pressKey` and `back`
531 // entries. find_by_smix returns the FIRST hit — that's
532 // documented behavior; consumers wanting disambiguation
533 // must scan VERB_TABLE directly.
534 let e = find_by_smix("pressKey").unwrap();
535 assert!(e.maestro_name == "pressKey" || e.maestro_name == "back");
536 }
537
538 #[test]
539 fn is_known_covers_both_forms() {
540 assert!(is_known_verb("tapOn"));
541 assert!(is_known_verb("tap"));
542 assert!(is_known_verb("assertVisible"));
543 assert!(is_known_verb("expect"));
544 assert!(!is_known_verb("nonexistent"));
545 }
546
547 #[test]
548 fn extendedwait_maps_to_expect() {
549 let e = find_by_maestro("extendedWaitUntil").unwrap();
550 assert_eq!(e.smix_name, "expect");
551 assert_eq!(e.category, VerbCategory::Assert);
552 assert_eq!(e.arg_shape, ArgShape::Mapping);
553 }
554
555 #[test]
556 fn table_has_no_duplicate_maestro_names() {
557 let mut seen = Vec::new();
558 for e in VERB_TABLE {
559 if seen.contains(&e.maestro_name) {
560 panic!("duplicate maestro_name: {}", e.maestro_name);
561 }
562 seen.push(e.maestro_name);
563 }
564 }
565
566 #[test]
567 fn each_category_has_at_least_one_entry() {
568 for cat in [
569 VerbCategory::Tap,
570 VerbCategory::Input,
571 VerbCategory::Assert,
572 VerbCategory::ControlFlow,
573 VerbCategory::Lifecycle,
574 VerbCategory::Media,
575 VerbCategory::Gesture,
576 VerbCategory::Device,
577 VerbCategory::SmixNative,
578 VerbCategory::Utility,
579 ] {
580 let count = verbs_in_category(cat).count();
581 assert!(count > 0, "category {cat:?} has no entries");
582 }
583 }
584
585 #[test]
586 fn table_covers_v0_2_5_migrate_default_rules() {
587 // Sanity: every maestro name from smix-migrate's original
588 // DEFAULT_RULES table is present here.
589 for name in [
590 "tapOn",
591 "assertVisible",
592 "assertNotVisible",
593 "extendedWaitUntil",
594 "inputText",
595 "eraseText",
596 "clearState",
597 "clearKeychain",
598 "runFlow",
599 "launchApp",
600 "stopApp",
601 "killApp",
602 "openLink",
603 "back",
604 "hideKeyboard",
605 "pressKey",
606 "scroll",
607 "scrollUntilVisible",
608 "swipe",
609 "retry",
610 ] {
611 assert!(
612 find_by_maestro(name).is_some(),
613 "v0.2.5 DEFAULT_RULES verb {name:?} missing from VERB_TABLE"
614 );
615 }
616 }
617
618 #[test]
619 fn table_covers_smix_native_verbs() {
620 for name in ["fixture", "webviewEval"] {
621 assert!(
622 find_by_smix(name).is_some() || find_by_maestro(name).is_some(),
623 "smix-native verb {name:?} missing from VERB_TABLE"
624 );
625 }
626 }
627}