Skip to main content

ravenrs_sheet/engine/macros/
debug.rs

1//! The macro debugger: a run that stops between steps.
2//!
3//! `macro.debug` starts a run that pauses before its first step, then steps, continues to the next
4//! breakpoint, runs to a chosen step, or stops. The run lives on the session between calls (one
5//! action per call) inside an undo group that spans them; Stop keeps the changes as one undo step
6//! or rolls them back. While a macro is being debugged its workbook is locked: only the
7//! debugger, queries and the commands in [`LOOKS_AROUND`] run there, and everything else, saving
8//! included, is refused ("Finish or stop the macro first"). Other workbooks stay editable. The
9//! debugger runs on its own, never as a step of a script or macro, and is never recorded.
10
11use 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
20/// The most breakpoints one call sets.
21pub const MAX_BREAKPOINTS: usize = 10_000;
22/// The most entries `changed` lists (ranges and sheet notes), as the footprint lists ranges.
23const MAX_LISTED: usize = 500;
24
25/// Commands that run on the debugged workbook between steps, besides `macro.debug` and queries.
26/// They only look around (the selection, the active sheet, Go To, Find, zoom, copying), switch to
27/// another window, close the workbook (which ends the run) or stop the run (Stop Action). The
28/// debugger puts the run's active sheet and selection back before its next step runs.
29pub 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
52/// Whether command `id` (a query when `query`) runs on a workbook the debugger locks.
53pub fn runs_while_locked(id: &str, query: bool) -> bool {
54    // `macro.check` is a query, but its dry run runs the macro's steps, which the lock refuses: it
55    // waits too, rather than report the lock as the failure of a step.
56    (query && id != "macro.check") || id == "macro.debug" || LOOKS_AROUND.contains(&id)
57}
58
59/// A macro being debugged.
60pub struct Debugger {
61    pub name: String,
62    pub doc: u64,
63    /// Out of the session only while a step runs.
64    machine: Option<Machine>,
65    token: Option<GroupToken>,
66    /// Breakpoints set for this run (step paths), on top of the steps' own.
67    pub breakpoints: BTreeSet<String>,
68    /// Only `breakpoints` stop the run; the steps' own flags are ignored (an editor that shows
69    /// its own breakpoints sends them all, so clearing one takes effect at once).
70    pub only_listed: bool,
71    /// The workbook when the run started, for "changed so far".
72    start: Arc<Workbook>,
73    /// Every step path in the macro, for checking `runTo` targets and breakpoints.
74    paths: BTreeSet<String>,
75    /// The workbooks the run has touched (by uid). Steps run on the macro's own workbook and the
76    /// policy refuses window and file commands, so this is the macro's workbook unless a command
77    /// reaches another one some other way; edits of these wait until the run ends.
78    touched: BTreeSet<u64>,
79    /// Steps the footprint's dry runs executed before the run (they count towards the run's
80    /// budget, not towards `executed`).
81    base: u64,
82    /// The conflict rule that shifted the data at the start (`shiftDown`, `shiftRight`).
83    shifted: Option<&'static str>,
84    /// Where the run left its workbook after the last action.
85    view: Option<View>,
86}
87
88/// The active sheet and the selections of the debugged workbook where the run left them.
89#[derive(Clone)]
90struct View {
91    sheet: usize,
92    selection: Selection,
93    sheet_selections: Vec<Selection>,
94}
95
96impl Debugger {
97    /// Whether edits of the workbook with this uid wait for the run to end.
98    pub fn locks(&self, doc: u64) -> bool {
99        self.touched.contains(&doc)
100    }
101
102    /// Whether one of the run's steps is running (its machine is out of the session).
103    pub fn stepping(&self) -> bool {
104        self.machine.is_none()
105    }
106
107    /// The run's own steps so far.
108    fn executed(&self) -> u64 {
109        self.machine.as_ref().map_or(0, |m| m.executed.saturating_sub(self.base))
110    }
111}
112
113/// Each open workbook's uid and its current version, to see which ones a step changes.
114fn versions(s: &Session) -> Vec<(u64, Arc<Workbook>)> {
115    s.docs.iter().map(|d| (d.uid, d.wb.clone())).collect()
116}
117
118/// Adds to the run's touched log the workbooks that changed since `before`.
119fn 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
127/// Remembers where the run left its workbook: the active sheet and the selections.
128fn 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
140/// Puts back the active sheet and the selections where the run left them, so the next step runs
141/// where `macro.run` would run it, however the person looked around meanwhile.
142fn 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        // The active sheet is view state: setting it doesn't make the workbook dirty.
147        d.wb.set_active_sheet(view.sheet);
148    }
149    d.selection = view.selection;
150    d.sheet_selections = view.sheet_selections;
151}
152
153/// The message for edits refused while a macro is being debugged.
154pub const BUSY: &str = "Finish or stop the macro first";
155
156/// Ends a debugger whose undo group is no longer open (something other than the debugger ended
157/// or rolled it back): its workbook is not locked any more. Returns the macro's name when it did.
158pub(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
166/// After Stop Action rolled back the undo groups of the workbook with uid `doc`: ends the
167/// debugger if its run was there. Returns the macro's name and the files the run wrote, which stay.
168pub(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
176/// Why an action finds the run already ended: its undo group was closed outside the debugger.
177fn 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
189/// `macro.debug {name?, inputs?, action: start|step|continue|runTo|stop|state, step?, keep?,
190/// breakpoints?, breakpointsOnly?}`.
191pub fn debug(s: &mut Session, p: &Json) -> Result<Json> {
192    // A step of a script or macro that started the debugger would leave it behind when the
193    // script fails and rolls back its groups, the debugger's among them.
194    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        // `{"action": {"runTo": {"step": "3"}}}`
203        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
259/// `true`, `false`, or not given (`null` counts as not given); anything else is refused.
260fn 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
268/// A step path from JSON: `"2.1"` or `3`.
269fn 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
277/// The `breakpoints` list: step paths of macro `name` (one of `paths`), at most
278/// [`MAX_BREAKPOINTS`].
279fn 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    // The run's machine: unless the rule is overwrite, it checks each step for writes over data.
309    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    // Each action has the time limit to itself; the dry runs just had theirs.
317    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
357/// Runs one step (`Step`), or steps until a breakpoint, the target step or the end.
358fn 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        // The macro.debug call panicked and `recover` has already rolled the run back.
396        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
416/// What every answer about the run's end or its state adds: the data shift at the start and the
417/// files the run wrote (they stay, whatever happens to the workbook).
418fn 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
427/// Ends the run after its last step (kept as one undo step, unless a Stop step asked for a
428/// rollback) or a failed step (rolled back).
429fn 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
469/// The result of a run that a failed step rolled back.
470fn 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
484/// Stops the run: keeps what it did as one undo step, or rolls it back.
485fn 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
508/// Where the run is: the next step with its parameters as they would run, the step that just
509/// ran, and what the run has changed in its workbook so far.
510fn 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
529/// What the run changed in its workbook since it started: cells as `Sheet!A1:B2` ranges and the
530/// sheets it added, deleted or renamed (at most [`MAX_LISTED`] entries), and how many cells
531/// changed on the sheets the workbook had at the start.
532fn 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
561/// After the `macro.debug` call itself panicked: if the run lost its place, roll it back so its
562/// undo group doesn't stay open.
563pub(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        // As if a step had panicked with the machine out of the session.
595        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        // As if a step had changed the other workbook too.
649        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        // Record Actions changes nothing itself; stopping it saves the script with a nested
680        // command, which would land in the run's undo group.
681        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        // The run's own steps still go ahead.
685        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        // The recording was kept: stopping now saves it.
689        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}