1use 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#[derive(Debug, thiserror::Error)]
43pub enum TraceError {
44 #[error("no trace bundle at {path}")]
46 NotFound {
47 path: PathBuf,
49 },
50 #[error("{manifest} in {path} is not readable JSON", manifest = TraceFiles::MANIFEST)]
52 ManifestUnreadable {
53 path: PathBuf,
55 },
56 #[error("not a Browser Commander trace bundle")]
58 NotATrace,
59 #[error("trace schema version {found} is newer than this reader ({TRACE_SCHEMA_VERSION})")]
61 UnsupportedVersion {
62 found: u64,
64 },
65 #[error("cannot read {path}: {source}")]
67 Io {
68 path: PathBuf,
70 source: io::Error,
72 },
73 #[error("{path} is not readable JSON")]
75 MemberUnreadable {
76 path: PathBuf,
78 },
79}
80
81#[derive(Debug, Clone, Default, PartialEq)]
83pub struct ParsedNdjson {
84 pub records: Vec<Value>,
86 pub truncated: bool,
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
92pub struct TraceCheckpoint {
93 pub index: Option<u32>,
95 pub name: Option<String>,
97 pub actor: Option<String>,
99 pub reason: Option<String>,
101 pub url: Option<String>,
103 pub at: Option<String>,
105 pub sequence: Option<u64>,
107 pub members: Map<String, Value>,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum ControlChangeKind {
115 Added,
117 Changed,
119 Removed,
121}
122
123impl ControlChangeKind {
124 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct ControlChange {
143 pub path: Option<String>,
145 pub change: ControlChangeKind,
147 pub before: Option<Value>,
149 pub after: Option<Value>,
151}
152
153#[derive(Debug, Clone)]
155pub struct Trace {
156 pub path: PathBuf,
158 pub manifest: TraceManifest,
160 pub events: Vec<Value>,
162 pub checkpoints: Vec<TraceCheckpoint>,
164 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
203pub 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
235fn 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
281pub 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 pub fn html(&self, index: u32) -> Result<Option<String>, TraceError> {
356 read_if_present(&self.checkpoint_member(index, ".html"))
357 }
358
359 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 pub fn screenshot(&self, index: u32) -> Result<Option<Vec<u8>>, TraceError> {
380 read_bytes_if_present(&self.checkpoint_member(index, ".png"))
381 }
382
383 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
419fn control_value(control: &Value) -> Option<Value> {
424 match field(control, "checked") {
425 None => field(control, "value"),
426 checked => checked,
427 }
428}
429
430pub fn diff_control_state(before: Option<&Value>, after: Option<&Value>) -> Vec<ControlChange> {
435 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 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 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 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}