Skip to main content

browser_commander/traces/
reader.rs

1//! Reading a trace bundle (issue #87).
2//!
3//! The reader assumes nothing finished cleanly. A run that was killed leaves a
4//! bundle with no manifest and a half-written last line; that bundle still
5//! holds the evidence someone is looking for, so it is repaired on open rather
6//! than rejected. This mirrors `js/src/traces/reader.js` and
7//! `python/src/browser_commander/traces/reader.py`, down to the messages, so a
8//! bundle reads the same whichever language opens it.
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use browser_commander::traces::{diff_control_state, read_trace};
14//!
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let trace = read_trace("./traces/run")?;
17//! println!("{} events", trace.events.len());
18//!
19//! let before = trace.state(1)?;
20//! let after = trace.state(2)?;
21//! for change in diff_control_state(before.as_ref(), after.as_ref()) {
22//!     println!("{} {}", change.change.as_str(), change.path.unwrap_or_default());
23//! }
24//! # Ok(())
25//! # }
26//! ```
27
28use std::fs;
29use std::io;
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33use serde_json::{Map, Value};
34
35use super::schema::{
36    assert_readable_manifest, sequence_name, TraceEvent, TraceFiles, TraceManifest, TraceMode,
37    TraceOutcome, TRACE_SCHEMA_VERSION,
38};
39
40/// Everything that can stop a bundle from being read.
41#[derive(Debug, thiserror::Error)]
42pub enum TraceError {
43    /// Neither a manifest nor a timeline was found at the path.
44    #[error("no trace bundle at {path}")]
45    NotFound {
46        /// Directory that was opened.
47        path: PathBuf,
48    },
49    /// A manifest exists but is not JSON this reader can parse.
50    #[error("{manifest} in {path} is not readable JSON", manifest = TraceFiles::MANIFEST)]
51    ManifestUnreadable {
52        /// Directory that was opened.
53        path: PathBuf,
54    },
55    /// The directory holds something else that happens to look similar.
56    #[error("not a Browser Commander trace bundle")]
57    NotATrace,
58    /// The bundle was written by a newer version of the format.
59    #[error("trace schema version {found} is newer than this reader ({TRACE_SCHEMA_VERSION})")]
60    UnsupportedVersion {
61        /// Version the bundle declares.
62        found: u64,
63    },
64    /// A member could not be read from disk.
65    #[error("cannot read {path}: {source}")]
66    Io {
67        /// Member that could not be read.
68        path: PathBuf,
69        /// What the filesystem reported.
70        source: io::Error,
71    },
72    /// A member exists but does not hold the JSON its name promises.
73    #[error("{path} is not readable JSON")]
74    MemberUnreadable {
75        /// Member that could not be parsed.
76        path: PathBuf,
77    },
78}
79
80/// What an NDJSON file held, and whether it ended mid-line.
81#[derive(Debug, Clone, Default, PartialEq)]
82pub struct ParsedNdjson {
83    /// Every record before the first unreadable line.
84    pub records: Vec<Value>,
85    /// Whether a line was cut short.
86    pub truncated: bool,
87}
88
89/// One named moment, as the timeline describes it.
90#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct TraceCheckpoint {
92    /// Checkpoint number, counting from one.
93    pub index: Option<u32>,
94    /// What the caller called it.
95    pub name: Option<String>,
96    /// Who asked for it: the caller, or the recorder itself.
97    pub actor: Option<String>,
98    /// Why the recorder took it, when nobody asked.
99    pub reason: Option<String>,
100    /// Where the page was.
101    pub url: Option<String>,
102    /// When it happened, in wall-clock time.
103    pub at: Option<String>,
104    /// Position on the timeline.
105    pub sequence: Option<u64>,
106    /// Bundle members the checkpoint wrote, by kind.
107    pub members: Map<String, Value>,
108}
109
110/// Which way a control went between two checkpoints.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum ControlChangeKind {
114    /// The control was not there before.
115    Added,
116    /// The control held something else before.
117    Changed,
118    /// The control is not there any more.
119    Removed,
120}
121
122impl ControlChangeKind {
123    /// The name the other languages use for this change.
124    pub fn as_str(self) -> &'static str {
125        match self {
126            Self::Added => "added",
127            Self::Changed => "changed",
128            Self::Removed => "removed",
129        }
130    }
131}
132
133impl std::fmt::Display for ControlChangeKind {
134    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        formatter.write_str(self.as_str())
136    }
137}
138
139/// One control that differs between two checkpoints.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct ControlChange {
142    /// Selector path of the control.
143    pub path: Option<String>,
144    /// What happened to it.
145    pub change: ControlChangeKind,
146    /// What it held at the earlier checkpoint.
147    pub before: Option<Value>,
148    /// What it holds at the later checkpoint.
149    pub after: Option<Value>,
150}
151
152/// An opened trace bundle.
153#[derive(Debug, Clone)]
154pub struct Trace {
155    /// Directory the bundle was read from.
156    pub path: PathBuf,
157    /// What the run was and how it ended.
158    pub manifest: TraceManifest,
159    /// The one ordered timeline.
160    pub events: Vec<Value>,
161    /// The named moments on that timeline.
162    pub checkpoints: Vec<TraceCheckpoint>,
163    /// Whether records are missing from the bundle.
164    pub truncated: bool,
165}
166
167fn read_if_present(path: &Path) -> Result<Option<String>, TraceError> {
168    match fs::read_to_string(path) {
169        Ok(body) => Ok(Some(body)),
170        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
171        Err(source) => Err(TraceError::Io {
172            path: path.to_path_buf(),
173            source,
174        }),
175    }
176}
177
178fn read_bytes_if_present(path: &Path) -> Result<Option<Vec<u8>>, TraceError> {
179    match fs::read(path) {
180        Ok(bytes) => Ok(Some(bytes)),
181        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
182        Err(source) => Err(TraceError::Io {
183            path: path.to_path_buf(),
184            source,
185        }),
186    }
187}
188
189fn absolute(path: &Path) -> PathBuf {
190    if path.is_absolute() {
191        return path.to_path_buf();
192    }
193    std::env::current_dir()
194        .map(|directory| directory.join(path))
195        .unwrap_or_else(|_| path.to_path_buf())
196}
197
198fn text(event: &Value, field: &str) -> Option<String> {
199    event.get(field)?.as_str().map(str::to_string)
200}
201
202/// Parse an NDJSON body, keeping everything before the first unreadable line.
203///
204/// A process killed mid-write leaves one partial line. Everything before it is
205/// still true, so the file is readable up to that point.
206pub fn parse_ndjson(body: Option<&str>) -> ParsedNdjson {
207    let mut records = Vec::new();
208
209    for line in body.unwrap_or("").split('\n') {
210        if line.trim().is_empty() {
211            continue;
212        }
213        match serde_json::from_str(line) {
214            Ok(record) => records.push(record),
215            Err(_) => {
216                return ParsedNdjson {
217                    records,
218                    truncated: true,
219                }
220            }
221        }
222    }
223
224    ParsedNdjson {
225        records,
226        truncated: false,
227    }
228}
229
230fn is_kind(event: &Value, kind: &str) -> bool {
231    event.get("kind").and_then(Value::as_str) == Some(kind)
232}
233
234/// Rebuild a manifest for a run that never reached `stop()`.
235///
236/// The timeline is the record of what happened; the manifest is rebuilt around
237/// it so a killed run still opens.
238fn rebuild_manifest(events: &[Value]) -> TraceManifest {
239    let started = events
240        .iter()
241        .find(|event| is_kind(event, TraceEvent::TRACE_START));
242
243    TraceManifest {
244        mode: Some(
245            started
246                .and_then(|event| text(event, "mode"))
247                .unwrap_or_else(|| TraceMode::CHECKPOINTS.to_string()),
248        ),
249        outcome: TraceOutcome::TRUNCATED.to_string(),
250        started_at: started.and_then(|event| text(event, "at")),
251        stopped_at: events.last().and_then(|event| text(event, "at")),
252        engine: started.and_then(|event| text(event, "engine")),
253        events: started
254            .and_then(|event| event.get("events"))
255            .and_then(Value::as_array)
256            .map(|sources| {
257                sources
258                    .iter()
259                    .filter_map(|source| source.as_str().map(str::to_string))
260                    .collect()
261            })
262            .unwrap_or_default(),
263        dom: started
264            .and_then(|event| event.get("dom"))
265            .and_then(Value::as_object)
266            .cloned()
267            .unwrap_or_default(),
268        counts: super::schema::TraceCounts {
269            events: events.len() as u64,
270            checkpoints: events
271                .iter()
272                .filter(|event| is_kind(event, TraceEvent::CHECKPOINT))
273                .count() as u64,
274            mutation_batches: 0,
275        },
276        ..TraceManifest::default()
277    }
278}
279
280/// Open a trace bundle.
281///
282/// # Errors
283///
284/// Returns an error when the directory holds no bundle, holds something that
285/// is not a Browser Commander trace, or was written against a newer schema
286/// version than this reader knows.
287pub fn read_trace(bundle_path: impl AsRef<Path>) -> Result<Trace, TraceError> {
288    let root = absolute(bundle_path.as_ref());
289    let manifest_body = read_if_present(&root.join(TraceFiles::MANIFEST))?;
290    let events_body = read_if_present(&root.join(TraceFiles::EVENTS))?;
291
292    if manifest_body.is_none() && events_body.is_none() {
293        return Err(TraceError::NotFound { path: root });
294    }
295
296    let parsed = parse_ndjson(events_body.as_deref());
297
298    let manifest = match manifest_body {
299        None => rebuild_manifest(&parsed.records),
300        Some(body) => {
301            let manifest: TraceManifest = serde_json::from_str(&body)
302                .map_err(|_| TraceError::ManifestUnreadable { path: root.clone() })?;
303            assert_readable_manifest(&manifest)?;
304            manifest
305        }
306    };
307
308    let checkpoints = parsed
309        .records
310        .iter()
311        .filter(|event| is_kind(event, TraceEvent::CHECKPOINT))
312        .map(|event| TraceCheckpoint {
313            index: event
314                .get("index")
315                .and_then(Value::as_u64)
316                .map(|index| index as u32),
317            name: text(event, "name"),
318            actor: text(event, "actor"),
319            reason: text(event, "reason"),
320            url: text(event, "url"),
321            at: text(event, "at"),
322            sequence: event.get("sequence").and_then(Value::as_u64),
323            members: event
324                .get("members")
325                .and_then(Value::as_object)
326                .cloned()
327                .unwrap_or_default(),
328        })
329        .collect();
330
331    let truncated = parsed.truncated || !manifest.is_complete();
332
333    Ok(Trace {
334        path: root,
335        manifest,
336        events: parsed.records,
337        checkpoints,
338        truncated,
339    })
340}
341
342impl Trace {
343    fn checkpoint_member(&self, index: u32, suffix: &str) -> PathBuf {
344        self.path
345            .join(TraceFiles::CHECKPOINTS_DIR)
346            .join(format!("{}{suffix}", sequence_name(index)))
347    }
348
349    /// Read one checkpoint's HTML.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error when the member exists but cannot be read.
354    pub fn html(&self, index: u32) -> Result<Option<String>, TraceError> {
355        read_if_present(&self.checkpoint_member(index, ".html"))
356    }
357
358    /// Read one checkpoint's live control state.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error when the member exists but is not readable JSON.
363    pub fn state(&self, index: u32) -> Result<Option<Value>, TraceError> {
364        let member = self.checkpoint_member(index, ".state.json");
365        match read_if_present(&member)? {
366            None => Ok(None),
367            Some(body) => serde_json::from_str(&body)
368                .map(Some)
369                .map_err(|_| TraceError::MemberUnreadable { path: member }),
370        }
371    }
372
373    /// Read one checkpoint's screenshot, when the recorder took one.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error when the member exists but cannot be read.
378    pub fn screenshot(&self, index: u32) -> Result<Option<Vec<u8>>, TraceError> {
379        read_bytes_if_present(&self.checkpoint_member(index, ".png"))
380    }
381
382    /// Read the mutation batches recorded after one checkpoint.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error when the member exists but cannot be read.
387    pub fn mutations(&self, index: u32) -> Result<Vec<Value>, TraceError> {
388        let member = self
389            .path
390            .join(TraceFiles::MUTATIONS_DIR)
391            .join(format!("{}.ndjson", sequence_name(index)));
392        Ok(parse_ndjson(read_if_present(&member)?.as_deref()).records)
393    }
394}
395
396fn controls(state: Option<&Value>) -> Vec<&Value> {
397    state
398        .and_then(|state| state.get("controls"))
399        .and_then(Value::as_array)
400        .map(|controls| controls.iter().collect())
401        .unwrap_or_default()
402}
403
404fn control_path(control: &Value) -> Option<String> {
405    control
406        .get("path")
407        .and_then(Value::as_str)
408        .map(str::to_string)
409}
410
411fn field(control: &Value, name: &str) -> Option<Value> {
412    match control.get(name) {
413        None | Some(Value::Null) => None,
414        Some(value) => Some(value.clone()),
415    }
416}
417
418/// The value a change is reported in terms of.
419///
420/// A checkbox changes by what it is checked to, not by the value it submits,
421/// so the reader reports the field the reviewer is actually looking at.
422fn control_value(control: &Value) -> Option<Value> {
423    match field(control, "checked") {
424        None => field(control, "value"),
425        checked => checked,
426    }
427}
428
429/// Diff two checkpoints' control state.
430///
431/// `before`/`after` per control is the shape a reviewer needs to answer "what
432/// did this step change?" without reading two HTML files side by side.
433pub fn diff_control_state(before: Option<&Value>, after: Option<&Value>) -> Vec<ControlChange> {
434    // An ordered list, not a map: controls that disappeared are reported in
435    // the order the earlier checkpoint saw them, as the other readers do.
436    let mut earlier: Vec<(Option<String>, &Value, bool)> = controls(before)
437        .into_iter()
438        .map(|control| (control_path(control), control, false))
439        .collect();
440    let mut changes = Vec::new();
441
442    for control in controls(after) {
443        let path = control_path(control);
444        let previous = earlier
445            .iter_mut()
446            .find(|(seen, _, taken)| !*taken && *seen == path);
447
448        let Some((_, previous, taken)) = previous else {
449            changes.push(ControlChange {
450                path,
451                change: ControlChangeKind::Added,
452                before: None,
453                after: field(control, "value"),
454            });
455            continue;
456        };
457        *taken = true;
458
459        if field(previous, "value") != field(control, "value")
460            || field(previous, "checked") != field(control, "checked")
461        {
462            changes.push(ControlChange {
463                path,
464                change: ControlChangeKind::Changed,
465                before: control_value(previous),
466                after: control_value(control),
467            });
468        }
469    }
470
471    changes.extend(earlier.into_iter().filter(|(_, _, taken)| !*taken).map(
472        |(path, control, _)| ControlChange {
473            path,
474            change: ControlChangeKind::Removed,
475            before: field(control, "value"),
476            after: None,
477        },
478    ));
479
480    changes
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use serde_json::json;
487
488    /// A directory that removes itself, so a failing test leaves no litter.
489    ///
490    /// The crate has no `tempfile` dev-dependency, and one reader test is not
491    /// a reason to add one.
492    struct TempDir(PathBuf);
493
494    impl TempDir {
495        fn new(name: &str) -> Self {
496            let nanos = std::time::SystemTime::now()
497                .duration_since(std::time::UNIX_EPOCH)
498                .expect("system clock is after the epoch")
499                .as_nanos();
500            let path = std::env::temp_dir()
501                .join(format!("bc-trace-{name}-{}-{nanos}", std::process::id()));
502            fs::create_dir_all(&path).expect("temp directory is writable");
503            Self(path)
504        }
505
506        fn path(&self) -> &Path {
507            &self.0
508        }
509    }
510
511    impl Drop for TempDir {
512        fn drop(&mut self) {
513            let _ = fs::remove_dir_all(&self.0);
514        }
515    }
516
517    /// Render records the way the recorder appends them.
518    fn ndjson(records: &[Value]) -> String {
519        records
520            .iter()
521            .map(|record| format!("{record}\n"))
522            .collect::<String>()
523    }
524
525    fn write(path: &Path, body: &str) {
526        fs::create_dir_all(path.parent().expect("member has a parent"))
527            .expect("bundle directory is writable");
528        fs::write(path, body).expect("member is writable");
529    }
530
531    /// Write a small bundle the tests can read back.
532    ///
533    /// A bundle that was not closed has no manifest, exactly like a run that
534    /// was killed. The bytes are the ones the JavaScript recorder writes, so a
535    /// change on either side that breaks the agreement fails here.
536    fn write_bundle(root: &Path, close: bool) -> PathBuf {
537        write(
538            &root.join(TraceFiles::CHECKPOINTS_DIR).join("0001.html"),
539            "<html><body>one</body></html>",
540        );
541        write(
542            &root
543                .join(TraceFiles::CHECKPOINTS_DIR)
544                .join("0001.state.json"),
545            &json!({
546                "url": "https://example.com/one",
547                "controls": [{"path": "input", "value": "before"}],
548            })
549            .to_string(),
550        );
551        write(
552            &root.join(TraceFiles::MUTATIONS_DIR).join("0001.ndjson"),
553            &ndjson(&[json!({"records": [{"type": "childList"}]})]),
554        );
555        write(
556            &root.join(TraceFiles::EVENTS),
557            &ndjson(&[
558                json!({
559                    "kind": TraceEvent::TRACE_START,
560                    "sequence": 1,
561                    "at": "2026-01-01T00:00:00.000Z",
562                    "mode": TraceMode::CHECKPOINTS,
563                    "engine": "playwright",
564                    "events": ["console"],
565                }),
566                json!({
567                    "kind": TraceEvent::CHECKPOINT,
568                    "sequence": 2,
569                    "at": "2026-01-01T00:00:01.000Z",
570                    "index": 1,
571                    "name": "start",
572                    "actor": "automation",
573                    "reason": "checkpoint",
574                    "url": "https://example.com/one",
575                    "members": {
576                        "html": "checkpoints/0001.html",
577                        "state": "checkpoints/0001.state.json",
578                    },
579                }),
580            ]),
581        );
582
583        if close {
584            write(
585                &root.join(TraceFiles::MANIFEST),
586                &json!({
587                    "schemaVersion": TRACE_SCHEMA_VERSION,
588                    "format": super::super::schema::TRACE_FORMAT,
589                    "mode": TraceMode::CHECKPOINTS,
590                    "outcome": TraceOutcome::COMPLETE,
591                    "engine": "playwright",
592                    "counts": {"checkpoints": 1, "events": 2, "mutationBatches": 1},
593                })
594                .to_string(),
595            );
596        }
597
598        root.to_path_buf()
599    }
600
601    #[test]
602    fn keeps_everything_before_a_half_written_line() {
603        let parsed = parse_ndjson(Some("{\"a\":1}\n{\"b\":2}\n{\"c\":"));
604
605        assert_eq!(parsed.records, vec![json!({"a": 1}), json!({"b": 2})]);
606        assert!(parsed.truncated);
607    }
608
609    #[test]
610    fn reads_an_empty_body_as_an_empty_timeline() {
611        let parsed = parse_ndjson(None);
612
613        assert!(parsed.records.is_empty());
614        assert!(!parsed.truncated);
615    }
616
617    #[test]
618    fn reads_the_manifest_timeline_and_checkpoint_members() {
619        let temp = TempDir::new("read");
620        let root = write_bundle(&temp.path().join("run"), true);
621
622        let trace = read_trace(&root).expect("bundle is readable");
623
624        assert_eq!(trace.manifest.outcome, TraceOutcome::COMPLETE);
625        assert_eq!(trace.checkpoints.len(), 1);
626        assert_eq!(trace.checkpoints[0].name.as_deref(), Some("start"));
627        assert_eq!(
628            trace.checkpoints[0].members["html"],
629            json!("checkpoints/0001.html")
630        );
631        assert!(trace
632            .html(1)
633            .expect("html is readable")
634            .expect("html was written")
635            .contains("one"));
636        assert_eq!(
637            trace
638                .state(1)
639                .expect("state is readable")
640                .expect("state was written")["url"],
641            json!("https://example.com/one")
642        );
643        assert_eq!(trace.mutations(1).expect("mutations are readable").len(), 1);
644        assert!(!trace.truncated);
645    }
646
647    #[test]
648    fn returns_nothing_for_a_checkpoint_member_that_was_dropped() {
649        let temp = TempDir::new("dropped");
650        let root = write_bundle(&temp.path().join("run"), true);
651
652        let trace = read_trace(&root).expect("bundle is readable");
653
654        assert!(trace
655            .html(2)
656            .expect("missing html is not an error")
657            .is_none());
658        assert!(trace
659            .state(2)
660            .expect("missing state is not an error")
661            .is_none());
662        assert!(trace
663            .screenshot(1)
664            .expect("missing screenshot is not an error")
665            .is_none());
666        assert!(trace
667            .mutations(2)
668            .expect("missing mutations are not an error")
669            .is_empty());
670    }
671
672    #[test]
673    fn rebuilds_a_manifest_for_a_run_that_never_stopped() {
674        let temp = TempDir::new("nostop");
675        let root = write_bundle(&temp.path().join("run"), false);
676
677        let trace = read_trace(&root).expect("bundle is readable");
678
679        assert_eq!(trace.manifest.outcome, TraceOutcome::TRUNCATED);
680        assert_eq!(trace.manifest.engine.as_deref(), Some("playwright"));
681        assert_eq!(trace.manifest.mode.as_deref(), Some(TraceMode::CHECKPOINTS));
682        assert_eq!(trace.manifest.counts.checkpoints, 1);
683        assert_eq!(trace.manifest.events, vec!["console".to_string()]);
684        assert!(trace.truncated);
685        assert!(trace
686            .html(1)
687            .expect("html is readable")
688            .expect("html was written")
689            .contains("one"));
690    }
691
692    #[test]
693    fn reads_a_timeline_whose_last_line_was_cut_off() {
694        let temp = TempDir::new("cutoff");
695        let root = write_bundle(&temp.path().join("run"), false);
696        let events = root.join(TraceFiles::EVENTS);
697        let body = format!(
698            "{}{}",
699            fs::read_to_string(&events).expect("timeline is readable"),
700            "{\"kind\":\"console\",\"text\":\"half"
701        );
702        fs::write(&events, body).expect("timeline is writable");
703
704        let trace = read_trace(&root).expect("bundle is readable");
705
706        assert!(trace.truncated);
707        assert_eq!(trace.events.len(), 2);
708    }
709
710    #[test]
711    fn refuses_a_directory_that_holds_no_trace() {
712        let temp = TempDir::new("empty");
713
714        let error = read_trace(temp.path().join("nothing-here")).expect_err("no bundle");
715
716        assert!(error.to_string().starts_with("no trace bundle at"));
717    }
718
719    #[test]
720    fn refuses_a_manifest_that_is_not_readable_json() {
721        let temp = TempDir::new("badjson");
722        let root = write_bundle(&temp.path().join("run"), true);
723        fs::write(root.join(TraceFiles::MANIFEST), "not json").expect("manifest is writable");
724
725        let error = read_trace(&root).expect_err("manifest is unreadable");
726
727        assert!(error.to_string().contains("is not readable JSON"));
728    }
729
730    #[test]
731    fn refuses_a_bundle_written_by_a_newer_format() {
732        let temp = TempDir::new("newer");
733        let root = write_bundle(&temp.path().join("run"), true);
734        fs::write(
735            root.join(TraceFiles::MANIFEST),
736            json!({"format": "browser-commander-trace", "schemaVersion": 99}).to_string(),
737        )
738        .expect("manifest is writable");
739
740        let error = read_trace(&root).expect_err("format is too new");
741
742        assert_eq!(
743            error.to_string(),
744            format!("trace schema version 99 is newer than this reader ({TRACE_SCHEMA_VERSION})")
745        );
746    }
747
748    #[test]
749    fn refuses_a_directory_that_holds_something_else() {
750        let temp = TempDir::new("other");
751        let root = write_bundle(&temp.path().join("run"), true);
752        fs::write(
753            root.join(TraceFiles::MANIFEST),
754            json!({"format": "something-else"}).to_string(),
755        )
756        .expect("manifest is writable");
757
758        let error = read_trace(&root).expect_err("not a trace");
759
760        assert_eq!(error.to_string(), "not a Browser Commander trace bundle");
761    }
762
763    #[test]
764    fn reports_what_a_step_changed_added_and_removed() {
765        let before = json!({"controls": [
766            {"path": "input#name", "value": "before"},
767            {"path": "input#gone", "value": "x"},
768            {"path": "input#same", "value": "stable"},
769        ]});
770        let after = json!({"controls": [
771            {"path": "input#name", "value": "after"},
772            {"path": "input#same", "value": "stable"},
773            {"path": "input#new", "value": "fresh"},
774        ]});
775
776        let changes = diff_control_state(Some(&before), Some(&after));
777
778        assert_eq!(
779            changes,
780            vec![
781                ControlChange {
782                    path: Some("input#name".to_string()),
783                    change: ControlChangeKind::Changed,
784                    before: Some(json!("before")),
785                    after: Some(json!("after")),
786                },
787                ControlChange {
788                    path: Some("input#new".to_string()),
789                    change: ControlChangeKind::Added,
790                    before: None,
791                    after: Some(json!("fresh")),
792                },
793                ControlChange {
794                    path: Some("input#gone".to_string()),
795                    change: ControlChangeKind::Removed,
796                    before: Some(json!("x")),
797                    after: None,
798                },
799            ]
800        );
801    }
802
803    #[test]
804    fn reports_a_checkbox_by_what_it_is_checked_to() {
805        let before = json!({"controls": [{"path": "input", "checked": false, "value": "on"}]});
806        let after = json!({"controls": [{"path": "input", "checked": true, "value": "on"}]});
807
808        let changes = diff_control_state(Some(&before), Some(&after));
809
810        assert_eq!(
811            changes,
812            vec![ControlChange {
813                path: Some("input".to_string()),
814                change: ControlChangeKind::Changed,
815                before: Some(json!(false)),
816                after: Some(json!(true)),
817            }]
818        );
819    }
820
821    #[test]
822    fn reads_a_missing_state_as_no_controls_at_all() {
823        assert!(diff_control_state(None, None).is_empty());
824    }
825}