1use std::collections::BTreeSet;
12use std::sync::Arc;
13
14use crate::model::Workbook;
15use serde_json::{Value as Json, json};
16
17use super::run::{Machine, StepError, rolled_back};
18use crate::engine::{EngineError, GroupToken, Result, Selection, Session};
19
20pub const MAX_BREAKPOINTS: usize = 10_000;
22const MAX_LISTED: usize = 500;
24
25pub const LOOKS_AROUND: &[&str] = &[
30 "selection.set",
31 "selection.move",
32 "selection.next",
33 "selection.row",
34 "selection.column",
35 "selection.currentRegion",
36 "edit.selectAll",
37 "sheet.activate",
38 "sheet.next",
39 "sheet.previous",
40 "edit.goTo",
41 "edit.goToSpecial",
42 "edit.find",
43 "edit.copy",
44 "view.zoom",
45 "view.zoom100",
46 "view.zoomToSelection",
47 "window.activate",
48 "file.close",
49 "automate.stopAction",
50];
51
52pub fn runs_while_locked(id: &str, query: bool) -> bool {
54 (query && id != "macro.check") || id == "macro.debug" || LOOKS_AROUND.contains(&id)
57}
58
59pub struct Debugger {
61 pub name: String,
62 pub doc: u64,
63 machine: Option<Machine>,
65 token: Option<GroupToken>,
66 pub breakpoints: BTreeSet<String>,
68 pub only_listed: bool,
71 start: Arc<Workbook>,
73 paths: BTreeSet<String>,
75 touched: BTreeSet<u64>,
79 base: u64,
82 shifted: Option<&'static str>,
84 view: Option<View>,
86}
87
88#[derive(Clone)]
90struct View {
91 sheet: usize,
92 selection: Selection,
93 sheet_selections: Vec<Selection>,
94}
95
96impl Debugger {
97 pub fn locks(&self, doc: u64) -> bool {
99 self.touched.contains(&doc)
100 }
101
102 pub fn stepping(&self) -> bool {
104 self.machine.is_none()
105 }
106
107 fn executed(&self) -> u64 {
109 self.machine.as_ref().map_or(0, |m| m.executed.saturating_sub(self.base))
110 }
111}
112
113fn versions(s: &Session) -> Vec<(u64, Arc<Workbook>)> {
115 s.docs.iter().map(|d| (d.uid, d.wb.clone())).collect()
116}
117
118fn note_touched(s: &mut Session, before: &[(u64, Arc<Workbook>)]) {
120 let changed: Vec<u64> =
121 s.docs.iter().filter(|d| before.iter().any(|(uid, wb)| *uid == d.uid && !Arc::ptr_eq(wb, &d.wb))).map(|d| d.uid).collect();
122 if let Some(dbg) = s.macro_debug.as_mut() {
123 dbg.touched.extend(changed);
124 }
125}
126
127fn remember_view(s: &mut Session) {
129 let Some(uid) = s.macro_debug.as_ref().map(|d| d.doc) else { return };
130 let view = s.docs.iter().find(|d| d.uid == uid).map(|d| View {
131 sheet: d.wb.active_sheet(),
132 selection: d.selection.clone(),
133 sheet_selections: d.sheet_selections.clone(),
134 });
135 if let Some(dbg) = s.macro_debug.as_mut() {
136 dbg.view = view;
137 }
138}
139
140fn put_back_view(s: &mut Session) {
143 let Some((uid, Some(view))) = s.macro_debug.as_ref().map(|d| (d.doc, d.view.clone())) else { return };
144 let Some(d) = s.docs.iter_mut().find(|d| d.uid == uid) else { return };
145 if view.sheet < d.wb.sheets.len() {
146 d.wb.set_active_sheet(view.sheet);
148 }
149 d.selection = view.selection;
150 d.sheet_selections = view.sheet_selections;
151}
152
153pub const BUSY: &str = "Finish or stop the macro first";
155
156pub(crate) fn drop_if_ended(s: &mut Session) -> Option<String> {
159 let d = s.macro_debug.as_ref()?;
160 if d.stepping() || d.token.as_ref().is_some_and(|t| s.group_is_open(t)) {
161 return None;
162 }
163 s.macro_debug.take().map(|d| d.name)
164}
165
166pub(crate) fn end_stopped(s: &mut Session, doc: u64) -> Option<(String, Vec<String>)> {
169 if !s.macro_debug.as_ref().is_some_and(|d| d.doc == doc && !d.stepping()) {
170 return None;
171 }
172 let d = s.macro_debug.take()?;
173 Some((d.name, d.machine.map(|m| m.wrote_files).unwrap_or_default()))
174}
175
176fn already_ended(name: &str) -> String {
178 format!("Macro '{name}' was no longer being debugged: its undo group had been closed outside the debugger, so nothing was kept or rolled back.")
179}
180
181enum Mode {
182 Step,
183 Continue,
184 RunTo(String),
185}
186
187const ACTIONS: [&str; 6] = ["start", "step", "continue", "runTo", "stop", "state"];
188
189pub fn debug(s: &mut Session, p: &Json) -> Result<Json> {
192 if s.depth() > 1 {
195 return Err(EngineError::Disabled("macro.debug".into(), "The macro debugger runs on its own, not as a step of a script or macro.".into()));
196 }
197 let ended = drop_if_ended(s);
198 let action = match p.get("action") {
199 None | Some(Json::Null) if s.macro_debug.is_some() || ended.is_some() => "state".to_string(),
200 None | Some(Json::Null) => "start".to_string(),
201 Some(Json::String(a)) => a.clone(),
202 Some(Json::Object(o)) if o.len() == 1 => o.keys().next().cloned().unwrap_or_default(),
204 Some(_) => return Err(bad("`action` must be start, step, continue, runTo, stop or state")),
205 };
206 if !ACTIONS.contains(&action.as_str()) {
207 return Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{action}'")));
208 }
209 if let Some(name) = ended
210 && action != "start"
211 {
212 return Err(EngineError::Other(already_ended(&name)));
213 }
214 let only = bool_param(p, "breakpointsOnly")?;
215 if action == "start" {
216 return start(s, p, only.unwrap_or(false));
217 }
218 let keep = bool_param(p, "keep")?;
219 let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?;
220 let target = if action == "runTo" {
221 let target = p
222 .get("step")
223 .or_else(|| p.pointer("/action/runTo/step"))
224 .and_then(path_of)
225 .ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?;
226 if !d.paths.contains(&target) {
227 return Err(bad(format!("macro '{}' has no step {target}", d.name)));
228 }
229 Some(target)
230 } else {
231 None
232 };
233 let breakpoints = p.get("breakpoints").filter(|v| !v.is_null()).map(|v| paths_param(v, &d.paths, &d.name)).transpose()?;
234 if let Some(d) = s.macro_debug.as_mut() {
235 if let Some(bps) = breakpoints {
236 d.breakpoints = bps;
237 }
238 if let Some(only) = only {
239 d.only_listed = only;
240 }
241 }
242 match (action.as_str(), target) {
243 ("step", _) => advance(s, Mode::Step),
244 ("continue", _) => advance(s, Mode::Continue),
245 ("runTo", Some(target)) => advance(s, Mode::RunTo(target)),
246 ("stop", _) => stop(s, keep.unwrap_or(false)),
247 _ => state(s),
248 }
249}
250
251fn bad(msg: impl Into<String>) -> EngineError {
252 crate::engine::cmd::bad("macro.debug", msg)
253}
254
255fn not_debugging() -> EngineError {
256 EngineError::Other("No macro is being debugged. Start one with action: start.".into())
257}
258
259fn bool_param(p: &Json, key: &str) -> Result<Option<bool>> {
261 match p.get(key) {
262 None | Some(Json::Null) => Ok(None),
263 Some(Json::Bool(b)) => Ok(Some(*b)),
264 Some(_) => Err(bad(format!("`{key}` must be true or false"))),
265 }
266}
267
268fn path_of(v: &Json) -> Option<String> {
270 match v {
271 Json::String(t) if !t.trim().is_empty() => Some(t.trim().to_string()),
272 Json::Number(n) => n.as_u64().map(|n| n.to_string()),
273 _ => None,
274 }
275}
276
277fn paths_param(v: &Json, paths: &BTreeSet<String>, name: &str) -> Result<BTreeSet<String>> {
280 let list = v.as_array().ok_or_else(|| bad("`breakpoints` must be a list of step paths"))?;
281 if list.len() > MAX_BREAKPOINTS {
282 return Err(bad(format!("`breakpoints` lists {} steps; at most {MAX_BREAKPOINTS} can be set", list.len())));
283 }
284 let mut out = BTreeSet::new();
285 for x in list {
286 let path = path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))?;
287 if !paths.contains(&path) {
288 return Err(bad(format!("macro '{name}' has no step {path} for a breakpoint")));
289 }
290 out.insert(path);
291 }
292 Ok(out)
293}
294
295fn start(s: &mut Session, p: &Json, only_listed: bool) -> Result<Json> {
296 if let Some(d) = &s.macro_debug {
297 return Err(EngineError::Other(format!("Macro '{}' is being debugged. Stop it first.", d.name)));
298 }
299 let name = p.get("name").and_then(Json::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("missing `name`"))?;
300 let b = super::run::begin(s, name, p)?;
301 let paths = all_paths(&b.mac.steps, "");
302 let breakpoints = match p.get("breakpoints").filter(|v| !v.is_null()) {
303 Some(v) => paths_param(v, &paths, &b.mac.name)?,
304 None => BTreeSet::new(),
305 };
306 let start_wb = s.doc()?.wb.clone();
307 let token = s.group_begin(&format!("Run Macro: {}", b.mac.name))?;
308 let (mut machine, shifted) = match super::run::make_way(s, &b) {
310 Ok(made) => made,
311 Err(e) => {
312 let _ = s.group_rollback(token);
313 return Err(e);
314 }
315 };
316 machine.restart_clock();
318 let base = machine.executed;
319 s.macro_debug = Some(Debugger {
320 name: b.mac.name.clone(),
321 doc: b.prepared.doc,
322 machine: Some(machine),
323 token: Some(token),
324 breakpoints,
325 only_listed,
326 start: start_wb,
327 paths,
328 touched: BTreeSet::from([b.prepared.doc]),
329 base,
330 shifted: shifted.then(|| b.rule.name()),
331 view: None,
332 });
333 if s.macro_debug.as_ref().and_then(|d| d.machine.as_ref()).is_some_and(Machine::is_done) {
334 return finish(s, None);
335 }
336 remember_view(s);
337 state(s)
338}
339
340fn all_paths(steps: &[crate::model::Step], prefix: &str) -> BTreeSet<String> {
341 let mut out = BTreeSet::new();
342 for (i, st) in steps.iter().enumerate() {
343 let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
344 match &st.kind {
345 crate::model::StepKind::Repeat { body, .. } => out.extend(all_paths(body, &path)),
346 crate::model::StepKind::If { then, otherwise, .. } => {
347 out.extend(all_paths(then, &format!("{path}.then")));
348 out.extend(all_paths(otherwise, &format!("{path}.else")));
349 }
350 _ => {}
351 }
352 out.insert(path);
353 }
354 out
355}
356
357fn advance(s: &mut Session, mode: Mode) -> Result<Json> {
359 put_back_view(s);
360 let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?;
361 let (name, base) = (d.name.clone(), d.base);
362 let breakpoints = d.breakpoints.clone();
363 let only_listed = d.only_listed;
364 let mut machine = d.machine.take().ok_or_else(not_debugging)?;
365 machine.restart_clock();
366 let before = versions(s);
367 let mut first = true;
368 let mut hit = false;
369 let mut outcome = Ok(());
370 while !machine.is_done() {
371 if !first {
372 let Some((path, st)) = machine.current() else { break };
373 if matches!(mode, Mode::Step) {
374 break;
375 }
376 if (st.breakpoint && !only_listed) || breakpoints.contains(&path) {
377 hit = true;
378 break;
379 }
380 if let Mode::RunTo(target) = &mode
381 && *target == path
382 {
383 break;
384 }
385 }
386 first = false;
387 outcome = machine.step(s);
388 if outcome.is_err() {
389 break;
390 }
391 }
392 let done = machine.is_done();
393 match s.macro_debug.as_mut() {
394 Some(d) => d.machine = Some(machine),
395 None => {
397 return match outcome {
398 Err(e) => Ok(failed(&name, machine.executed.saturating_sub(base), &e)),
399 Ok(()) => Err(not_debugging()),
400 };
401 }
402 }
403 note_touched(s, &before);
404 if let Err(e) = outcome {
405 return finish(s, Some(e));
406 }
407 if done {
408 return finish(s, None);
409 }
410 remember_view(s);
411 let mut out = state(s)?;
412 out["atBreakpoint"] = json!(hit);
413 Ok(out)
414}
415
416fn add_notes(out: &mut Json, d: &Debugger, wrote_files: &[String]) {
419 if let Some(rule) = d.shifted {
420 out["shifted"] = json!(rule);
421 }
422 if !wrote_files.is_empty() {
423 out["wroteFiles"] = json!(wrote_files);
424 }
425}
426
427fn finish(s: &mut Session, error: Option<StepError>) -> Result<Json> {
430 let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) };
431 let (changed, cells) = changed(s, &d);
432 let executed = d.executed();
433 let machine = d.machine.take();
434 let stopped = machine.as_ref().and_then(|m| m.stopped.clone());
435 let wrote = machine.map(|m| m.wrote_files).unwrap_or_default();
436 let rollback = error.is_some() || matches!(stopped, Some((_, true)));
437 let ended = match d.token.take() {
438 Some(token) if rollback => s.group_rollback(token).is_ok(),
439 Some(token) => s.group_end(token).is_ok(),
440 None => false,
441 };
442 if !ended {
443 let step = error.map(|e| format!("{} ", e.describe())).unwrap_or_default();
444 return Err(EngineError::Other(format!("{step}{}", already_ended(&d.name))));
445 }
446 if let Some(e) = &error {
447 return Ok(failed(&d.name, executed, e));
448 }
449 let mut out = json!({"macro": d.name, "executed": executed});
450 match &stopped {
451 Some((msg, rolled)) => {
452 out["state"] = json!("stopped");
453 out["stopped"] = json!(msg);
454 out["kept"] = json!(!rolled);
455 if *rolled {
456 out["message"] = json!(rolled_back(&wrote));
457 }
458 }
459 None => out["state"] = json!("finished"),
460 }
461 if !rollback {
462 out["changed"] = json!(changed);
463 out["changedCells"] = json!(cells);
464 }
465 add_notes(&mut out, &d, &wrote);
466 Ok(out)
467}
468
469fn failed(name: &str, executed: u64, e: &StepError) -> Json {
471 let mut out = json!({
472 "macro": name,
473 "executed": executed,
474 "state": "failed",
475 "error": serde_json::to_value(e).unwrap_or(Json::Null),
476 "message": format!("{} {}", e.describe(), rolled_back(&e.wrote_files)),
477 });
478 if !e.wrote_files.is_empty() {
479 out["wroteFiles"] = json!(e.wrote_files);
480 }
481 out
482}
483
484fn stop(s: &mut Session, keep: bool) -> Result<Json> {
486 let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) };
487 let (changed, cells) = changed(s, &d);
488 let ended = match d.token.take() {
489 Some(token) if keep => s.group_end(token).is_ok(),
490 Some(token) => s.group_rollback(token).is_ok(),
491 None => false,
492 };
493 if !ended {
494 return Err(EngineError::Other(already_ended(&d.name)));
495 }
496 let wrote = d.machine.as_ref().map(|m| m.wrote_files.clone()).unwrap_or_default();
497 let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": d.executed()});
498 if keep {
499 out["changed"] = json!(changed);
500 out["changedCells"] = json!(cells);
501 } else {
502 out["message"] = json!(rolled_back(&wrote));
503 }
504 add_notes(&mut out, &d, &wrote);
505 Ok(out)
506}
507
508fn state(s: &mut Session) -> Result<Json> {
511 let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?;
512 let machine = d.machine.as_ref().ok_or_else(not_debugging)?;
513 let wb = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()).ok_or(EngineError::NoDocument)?;
514 let (changed, cells) = changed(s, d);
515 let mut out = json!({
516 "state": "paused",
517 "macro": d.name,
518 "current": machine.current_json(&wb),
519 "lastParams": machine.last_params,
520 "executed": d.executed(),
521 "breakpoints": d.breakpoints,
522 "changed": changed,
523 "changedCells": cells,
524 });
525 add_notes(&mut out, d, &machine.wrote_files);
526 Ok(out)
527}
528
529fn changed(s: &Session, d: &Debugger) -> (Vec<String>, usize) {
533 let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return (vec![], 0) };
534 let (pairs, added) = super::run::pair_sheets(&d.start, &now);
535 let mut out = Vec::new();
536 let mut count = 0usize;
537 for (bi, ai) in pairs {
538 let Some(before) = d.start.sheets.get(bi) else { continue };
539 let Some(after) = ai.and_then(|a| now.sheets.get(a)) else {
540 out.push(format!("{} (deleted sheet)", before.name));
541 continue;
542 };
543 if before.name != after.name {
544 out.push(format!("{} (renamed from {})", after.name, before.name));
545 }
546 if Arc::ptr_eq(before, after) {
547 continue;
548 }
549 let mut cells = before.cells.diff(&after.cells);
550 count = count.saturating_add(cells.len());
551 if out.len() < MAX_LISTED {
552 let q = super::resolve::quote_sheet(&after.name);
553 out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}")));
554 }
555 }
556 out.extend(added.into_iter().filter_map(|a| now.sheets.get(a)).map(|sh| format!("{} (new sheet)", sh.name)));
557 out.truncate(MAX_LISTED);
558 (out, count)
559}
560
561pub(crate) fn recover(s: &mut Session) {
564 if s.macro_debug.as_ref().is_some_and(Debugger::stepping)
565 && let Some(mut d) = s.macro_debug.take()
566 && let Some(token) = d.token.take()
567 {
568 let _ = s.group_rollback(token);
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use serde_json::json;
575
576 use crate::engine::Session;
577
578 #[test]
579 fn a_run_that_lost_its_place_is_rolled_back() {
580 let mut s = Session::new();
581 s.new_workbook();
582 let m = crate::engine::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
583 {"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
584 {"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
585 ]}))
586 .unwrap();
587 let d = s.doc_mut().unwrap();
588 let mut wb = (*d.wb).clone();
589 wb.macros.push(m);
590 d.wb = std::sync::Arc::new(wb);
591 let start = s.doc().unwrap().wb.clone();
592 s.execute("macro.debug", json!({"name": "M"})).unwrap();
593 s.execute("macro.debug", json!({"action": "step"})).unwrap();
594 s.macro_debug.as_mut().unwrap().machine = None;
596 super::recover(&mut s);
597 assert!(s.macro_debug.is_none());
598 assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start));
599 assert!(!s.in_group(s.doc().unwrap().uid));
600 s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap();
601 }
602
603 #[test]
604 fn a_step_that_panics_reports_the_step_failure() {
605 crate::engine::register_test_command("test.debugStepPanics", |_, _| panic!("boom"));
606 let mut s = Session::new();
607 s.new_workbook();
608 let m = crate::engine::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
609 {"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
610 {"command": "test.debugStepPanics", "params": {}}
611 ]}))
612 .unwrap();
613 let d = s.doc_mut().unwrap();
614 let mut wb = (*d.wb).clone();
615 wb.macros.push(m);
616 d.wb = std::sync::Arc::new(wb);
617 let start = s.doc().unwrap().wb.clone();
618 s.execute("macro.debug", json!({"name": "M"})).unwrap();
619 s.execute("macro.debug", json!({"action": "step"})).unwrap();
620 let r = s.execute("macro.debug", json!({"action": "step"})).unwrap();
621 assert_eq!(r["state"], "failed", "{r}");
622 assert_eq!(r["macro"], "M");
623 assert_eq!(r["error"]["step"], "2");
624 assert!(r["message"].as_str().unwrap().contains("boom"), "{r}");
625 assert!(s.macro_debug.is_none());
626 assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start));
627 assert!(!s.in_group(s.doc().unwrap().uid));
628 }
629
630 #[test]
631 fn a_workbook_in_the_touched_log_waits_until_the_run_ends() {
632 let mut s = Session::new();
633 s.new_workbook();
634 let m = crate::engine::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
635 {"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
636 {"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
637 ]}))
638 .unwrap();
639 let d = s.doc_mut().unwrap();
640 let mut wb = (*d.wb).clone();
641 wb.macros.push(m);
642 d.wb = std::sync::Arc::new(wb);
643 s.new_workbook();
644 let other = s.doc().unwrap().uid;
645 s.set_active(0);
646 s.execute("macro.debug", json!({"name": "M"})).unwrap();
647 assert!(!s.macro_debug.as_ref().unwrap().locks(other));
648 let before = super::versions(&s);
650 let d = s.docs.iter_mut().find(|d| d.uid == other).unwrap();
651 let mut wb = (*d.wb).clone();
652 wb.props.title = "changed by the macro".into();
653 d.wb = std::sync::Arc::new(wb);
654 super::note_touched(&mut s, &before);
655 assert!(s.macro_debug.as_ref().unwrap().locks(other));
656 s.set_active(1);
657 assert!(s.execute("cell.set", json!({"cell": "B1", "input": "no"})).is_err());
658 s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap();
659 s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap();
660 }
661
662 #[test]
663 fn edits_run_by_another_command_wait_too() {
664 let mut s = Session::new();
665 s.new_workbook();
666 let m = crate::engine::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
667 {"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
668 {"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
669 ]}))
670 .unwrap();
671 let d = s.doc_mut().unwrap();
672 let mut wb = (*d.wb).clone();
673 wb.macros.push(m);
674 d.wb = std::sync::Arc::new(wb);
675 s.execute("automate.recordActions", json!({})).unwrap();
676 s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap();
677 s.execute("macro.debug", json!({"name": "M"})).unwrap();
678 s.execute("macro.debug", json!({"action": "step"})).unwrap();
679 let e = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap_err().to_string();
682 assert!(e.contains(super::BUSY), "{e}");
683 assert!(s.execute("automate.allScripts", json!({})).unwrap().as_array().unwrap().is_empty());
684 let r = s.execute("macro.debug", json!({"action": "step"})).unwrap();
686 assert_eq!(r["executed"], 2, "{r}");
687 assert!(s.macro_debug.is_none());
688 let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap();
690 assert_eq!(saved["saved"], "S", "{saved}");
691 let scripts = s.execute("automate.allScripts", json!({})).unwrap();
692 assert_eq!(scripts[0]["name"], "S", "{scripts}");
693 }
694}