1use 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#[derive(Debug, thiserror::Error)]
42pub enum TraceError {
43 #[error("no trace bundle at {path}")]
45 NotFound {
46 path: PathBuf,
48 },
49 #[error("{manifest} in {path} is not readable JSON", manifest = TraceFiles::MANIFEST)]
51 ManifestUnreadable {
52 path: PathBuf,
54 },
55 #[error("not a Browser Commander trace bundle")]
57 NotATrace,
58 #[error("trace schema version {found} is newer than this reader ({TRACE_SCHEMA_VERSION})")]
60 UnsupportedVersion {
61 found: u64,
63 },
64 #[error("cannot read {path}: {source}")]
66 Io {
67 path: PathBuf,
69 source: io::Error,
71 },
72 #[error("{path} is not readable JSON")]
74 MemberUnreadable {
75 path: PathBuf,
77 },
78}
79
80#[derive(Debug, Clone, Default, PartialEq)]
82pub struct ParsedNdjson {
83 pub records: Vec<Value>,
85 pub truncated: bool,
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct TraceCheckpoint {
92 pub index: Option<u32>,
94 pub name: Option<String>,
96 pub actor: Option<String>,
98 pub reason: Option<String>,
100 pub url: Option<String>,
102 pub at: Option<String>,
104 pub sequence: Option<u64>,
106 pub members: Map<String, Value>,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum ControlChangeKind {
114 Added,
116 Changed,
118 Removed,
120}
121
122impl ControlChangeKind {
123 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct ControlChange {
142 pub path: Option<String>,
144 pub change: ControlChangeKind,
146 pub before: Option<Value>,
148 pub after: Option<Value>,
150}
151
152#[derive(Debug, Clone)]
154pub struct Trace {
155 pub path: PathBuf,
157 pub manifest: TraceManifest,
159 pub events: Vec<Value>,
161 pub checkpoints: Vec<TraceCheckpoint>,
163 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
202pub 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
234fn 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
280pub 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 pub fn html(&self, index: u32) -> Result<Option<String>, TraceError> {
355 read_if_present(&self.checkpoint_member(index, ".html"))
356 }
357
358 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 pub fn screenshot(&self, index: u32) -> Result<Option<Vec<u8>>, TraceError> {
379 read_bytes_if_present(&self.checkpoint_member(index, ".png"))
380 }
381
382 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
418fn control_value(control: &Value) -> Option<Value> {
423 match field(control, "checked") {
424 None => field(control, "value"),
425 checked => checked,
426 }
427}
428
429pub fn diff_control_state(before: Option<&Value>, after: Option<&Value>) -> Vec<ControlChange> {
434 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 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 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 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}