Skip to main content

browser_commander/traces/
reader.rs

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