1pub const PLAYER_JS: &str = concat!(include_str!("vt.js"), "\n", include_str!("player.js"));
14
15pub const PLAYER_CSS: &str = include_str!("player.css");
18
19#[cfg(test)]
20mod tests {
21 use super::*;
22
23 #[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 #[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; };
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}