Skip to main content

beecast_player/
lib.rs

1//! The first-party beecast player as a crate: clean-room, dependency-free
2//! asciicast (v1/v2/v3) player and VT100/xterm-subset terminal emulator. Consumers inline
3//! the two constants whole — a `<script>` element and a `<style>` element — and get the
4//! full player: parsing, emulation, playback with idle compression, chapter markers,
5//! keyboard control, and live-follow `append` for recordings that are still growing.
6//!
7//! The JS globals are `BeeCastVT` (the DOM-free core) and `BeeCastPlayer` (the public
8//! API); this crate is the component's canonical home.
9
10/// The player bundle: `vt.js` (asciicast parsing + the VT emulator + the pacing map, all
11/// DOM-free) then `player.js` (the renderer, playback clock, and controls). Inline it in
12/// one `<script>` element; it performs no network requests and loads no workers.
13pub const PLAYER_JS: &str = concat!(include_str!("vt.js"), "\n", include_str!("player.js"));
14
15/// The player chrome and terminal palette. All colors are CSS variables, so the embedding
16/// page can theme them; nothing is fetched (no fonts, no images).
17pub const PLAYER_CSS: &str = include_str!("player.css");
18
19#[cfg(test)]
20mod tests {
21  use super::*;
22
23  /// Every embedder's self-containment claim depends on the bundle itself: it must not
24  /// contain a literal `</script`, must not reference workers, and its CSS must not pull
25  /// fonts or images. Asserted here so a player change that breaks any of it fails loudly.
26  /// And the bundle must be the first-party clean-room player — no third-party code, and
27  /// therefore no third-party license, rides into any embedding page.
28  #[test]
29  fn player_bundle_is_inline_safe_and_first_party() {
30    assert!(!PLAYER_JS.contains("</script"), "bundle would terminate an inline <script>");
31    assert!(!PLAYER_JS.contains("<!--"), "bundle would enter the script double-escaped state");
32    assert!(!PLAYER_JS.to_lowercase().contains("worker"), "bundle must not load a worker sidecar");
33    assert!(!PLAYER_CSS.contains("url("), "player CSS must not fetch fonts/images");
34    assert!(PLAYER_JS.contains("BeeCastPlayer") && PLAYER_JS.contains("Clean-room implementation"));
35    for banned in ["asciinema-player", "AsciinemaPlayer", "@license", "Apache"] {
36      assert!(!PLAYER_JS.contains(banned) && !PLAYER_CSS.contains(banned), "third-party marker '{banned}'");
37    }
38  }
39
40  /// Behavior tests for the DOM-free core (`BeeCastVT`), run under Node: parsing, the
41  /// emulator subset, live-follow appends, and the pacing map. Skips silently when `node`
42  /// is not installed — the structural assertions above still gate the bundle.
43  #[test]
44  fn vt_core_node_selftest() {
45    let dir = std::env::temp_dir().join(format!("beecast-vt-selftest-{}", std::process::id()));
46    std::fs::create_dir_all(&dir).unwrap();
47    let bundle = dir.join("player.js");
48    std::fs::write(&bundle, PLAYER_JS).unwrap();
49    let script = r#"
50const assert = require('assert');
51require(process.argv[2]);
52const VT = globalThis.BeeCastVT;
53
54// v3: intervals sum; term size from the header; resize + marker events survive; # comments skip.
55let c = VT.parseCast('{"version":3,"term":{"cols":10,"rows":3}}\n# note\n[0.5,"o","hi"]\n[0.5,"m","chapter"]\n[1.0,"r","20x5"]\n');
56assert.strictEqual(c.cols, 10); assert.strictEqual(c.rows, 3);
57assert.strictEqual(c.events.length, 3);
58assert.strictEqual(c.duration, 2);
59
60// v2: absolute times.
61c = VT.parseCast('{"version":2,"width":80,"height":24}\n[0.5,"o","a"]\n[2.0,"o","b"]\n');
62assert.strictEqual(c.duration, 2); assert.strictEqual(c.events[1].t, 2);
63
64// v1: one JSON doc, stdout deltas.
65c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"],[0.2,"y"]]}');
66assert.strictEqual(c.cols, 5); assert.strictEqual(c.events.length, 2);
67
68// Live-follow: appendCast grows a v3 cast across arbitrary chunk boundaries.
69c = VT.parseCast('{"version":3,"term":{"cols":10,"rows":3}}\n[1.0,"o","a"]\n');
70assert.strictEqual(VT.appendCast(c, '[0.5,"o",'), 0);            // partial line buffers
71assert.strictEqual(c.duration, 1);
72assert.strictEqual(VT.appendCast(c, '"b"]\n[0.5,"m","x"]\n'), 2); // completed + one more
73assert.strictEqual(c.duration, 2);
74assert.strictEqual(c.events.length, 3);
75assert.strictEqual(c.events[1].t, 1.5);
76
77// v2 appends carry absolute times; stray comment and header lines are skipped.
78c = VT.parseCast('{"version":2,"width":80,"height":24}\n[1.0,"o","a"]\n');
79VT.appendCast(c, '# noise\n{"version":2}\n[3.0,"o","b"]\n');
80assert.strictEqual(c.duration, 3); assert.strictEqual(c.events[1].t, 3);
81
82// A load-time partial trailing line is held back and completed by the first append.
83c = VT.parseCast('{"version":3,"term":{"cols":4,"rows":1}}\n[1.0,"o","hi"]\n[2.0,"o');
84assert.strictEqual(c.events.length, 1);
85VT.appendCast(c, '","yo"]\n');
86assert.strictEqual(c.events.length, 2); assert.strictEqual(c.duration, 3);
87
88// v1 casts never grow.
89c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"]]}');
90assert.strictEqual(VT.appendCast(c, '[1.0,"o","y"]\n'), 0);
91
92// The pacing map extends in place and keeps both directions consistent.
93const ev = [{t:1},{t:2}];
94const pacing = VT.buildPacing(ev, 2, null);
95ev.push({t:10});
96VT.extendPacing(pacing, ev, 2, 10);
97assert.strictEqual(pacing.pacedDuration, 10);
98assert.strictEqual(VT.mapTime(pacing.rec, pacing.paced, 10), 10);
99const limited = VT.buildPacing([{t:1}], 1, 2);
100VT.extendPacing(limited, [{t:1},{t:9}], 1, 9);                    // an 8s silence plays as 2s
101assert.strictEqual(limited.pacedDuration, 3);
102assert.strictEqual(VT.mapTime(limited.paced, limited.rec, 3), 9);
103
104// Plain text, CR/LF, cursor addressing, erase.
105let t = new VT.Term(10, 3);
106t.write('hello\r\nworld');
107assert.deepStrictEqual(t.textLines(), ['hello', 'world', '']);
108t.write('\x1b[1;3Hga');
109assert.strictEqual(t.textLines()[0], 'hegao');
110t.write('\x1b[2J');
111assert.deepStrictEqual(t.textLines(), ['', '', '']);
112
113// SGR runs merge; 16/256/true color.
114t = new VT.Term(10, 1);
115t.write('\x1b[31mred\x1b[0m ok');
116const runs = t.snapshot().rows[0];
117assert.strictEqual(runs[0].text, 'red'); assert.strictEqual(runs[0].fg, 1);
118t = new VT.Term(4, 1);
119t.write('\x1b[38;5;196mX\x1b[38;2;1;2;3mY');
120assert.strictEqual(t.snapshot().rows[0][0].fg, 196);
121assert.strictEqual(t.snapshot().rows[0][1].fg, '#010203');
122
123// Deferred wrap.
124t = new VT.Term(3, 2);
125t.write('abc');
126assert.strictEqual(t.snapshot().cursor.y, 0);
127t.write('d');
128assert.deepStrictEqual(t.textLines(), ['abc', 'd']);
129
130// Scroll region.
131t = new VT.Term(5, 4);
132t.write('aa\r\nbb\r\ncc\r\ndd');
133t.write('\x1b[2;3r\x1b[3;1H\n');
134assert.strictEqual(t.textLines()[0], 'aa');
135assert.strictEqual(t.textLines()[1], 'cc');
136assert.strictEqual(t.textLines()[3], 'dd');
137
138// Alternate screen restores the primary.
139t = new VT.Term(5, 2);
140t.write('main');
141t.write('\x1b[?1049h\x1b[Halt');
142assert.strictEqual(t.textLines()[0], 'alt');
143t.write('\x1b[?1049l');
144assert.strictEqual(t.textLines()[0], 'main');
145
146// DEC special graphics (tmux borders); OSC consumed; cursor visibility.
147t = new VT.Term(4, 1);
148t.write('\x1b(0qqx\x1b(B');
149assert.strictEqual(t.textLines()[0], '──│');
150t = new VT.Term(8, 1);
151t.write('\x1b]0;title\x07ok');
152assert.strictEqual(t.textLines()[0], 'ok');
153t.write('\x1b[?25l');
154assert.strictEqual(t.snapshot().cursor.visible, false);
155
156console.log('vt selftest OK');
157"#;
158    let spawned = std::process::Command::new("node")
159      .arg("-")
160      .arg(&bundle)
161      .stdin(std::process::Stdio::piped())
162      .stdout(std::process::Stdio::piped())
163      .stderr(std::process::Stdio::piped())
164      .spawn();
165    let Ok(mut child) = spawned else {
166      let _ = std::fs::remove_dir_all(&dir);
167      return; // no node on this machine — the structural tests above still ran
168    };
169    use std::io::Write;
170    child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
171    let out = child.wait_with_output().unwrap();
172    let _ = std::fs::remove_dir_all(&dir);
173    assert!(
174      out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
175      "vt selftest failed:\nstdout: {}\nstderr: {}",
176      String::from_utf8_lossy(&out.stdout),
177      String::from_utf8_lossy(&out.stderr)
178    );
179  }
180}