1pub const PLAYER_JS: &str =
16 concat!(include_str!("vt.js"), "\n", include_str!("controller.js"), "\n", include_str!("player.js"),);
17
18pub const PLAYER_CSS: &str = include_str!("player.css");
21
22#[cfg(test)]
23mod tests {
24 use super::*;
25
26 #[test]
32 fn player_bundle_is_inline_safe_and_first_party() {
33 assert!(!PLAYER_JS.contains("</script"), "bundle would terminate an inline <script>");
34 assert!(!PLAYER_JS.contains("<!--"), "bundle would enter the script double-escaped state");
35 assert!(!PLAYER_JS.to_lowercase().contains("worker"), "bundle must not load a worker sidecar");
36 assert!(!PLAYER_CSS.contains("url("), "player CSS must not fetch fonts/images");
37 assert!(PLAYER_JS.contains("BeeCastPlayer") && PLAYER_JS.contains("Clean-room implementation"));
38 assert!(PLAYER_JS.contains("BeeCastController"));
39 assert!(PLAYER_JS.contains("beecast-player"));
40 for banned in ["asciinema-player", "AsciinemaPlayer", "@license", "Apache"] {
41 assert!(!PLAYER_JS.contains(banned) && !PLAYER_CSS.contains(banned), "third-party marker '{banned}'");
42 }
43 }
44
45 #[test]
47 fn public_api_surface_is_documented() {
48 for key in [
49 "BeeCastVT",
50 "BeeCastController",
51 "BeeCastPlayer",
52 "parseCast",
53 "appendCast",
54 "buildPacing",
55 "extendPacing",
56 "mapTime",
57 "create",
58 "getState",
59 "subscribe",
60 "setSpeed",
61 "publicMethods",
62 "supportedCssVariables",
63 "nonPublicFields",
64 ] {
65 assert!(PLAYER_JS.contains(key), "public surface missing {key}");
66 }
67 for token in
68 ["--beecast-color-surface", "--beecast-color-accent", "--beecast-color-focus", "--beecast-font-terminal"]
69 {
70 assert!(PLAYER_CSS.contains(token), "theme token missing {token}");
71 }
72 }
73
74 #[test]
77 fn vt_core_node_selftest() {
78 let dir = std::env::temp_dir().join(format!("beecast-vt-selftest-{}", std::process::id()));
79 std::fs::create_dir_all(&dir).unwrap();
80 let bundle = dir.join("player.js");
81 std::fs::write(&bundle, PLAYER_JS).unwrap();
82 let script = r#"
83const assert = require('assert');
84require(process.argv[2]);
85const VT = globalThis.BeeCastVT;
86const C = globalThis.BeeCastController;
87const P = globalThis.BeeCastPlayer;
88
89// ---- Phase 0: public keys --------------------------------------------------------------
90for (const k of ['parseCast','appendCast','buildPacing','extendPacing','mapTime','Term']) {
91 assert.strictEqual(typeof VT[k], 'function', 'BeeCastVT.' + k);
92}
93assert.strictEqual(typeof C.create, 'function');
94assert.strictEqual(typeof P.create, 'function');
95assert.ok(Array.isArray(P.publicMethods));
96for (const m of ['create','play','pause','toggle','seek','getCurrentTime','append','dispose']) {
97 assert.ok(P.publicMethods.includes(m), 'publicMethods missing ' + m);
98}
99assert.ok(P.nonPublicFields.includes('playing'));
100assert.ok(P.nonPublicFields.includes('pacedPos'));
101assert.ok(P.supportedCssVariables.includes('--beecast-color-accent'));
102
103// ---- VT core (existing coverage) -------------------------------------------------------
104let 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');
105assert.strictEqual(c.cols, 10); assert.strictEqual(c.rows, 3);
106assert.strictEqual(c.events.length, 3);
107assert.strictEqual(c.duration, 2);
108
109c = VT.parseCast('{"version":2,"width":80,"height":24}\n[0.5,"o","a"]\n[2.0,"o","b"]\n');
110assert.strictEqual(c.duration, 2); assert.strictEqual(c.events[1].t, 2);
111
112c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"],[0.2,"y"]]}');
113assert.strictEqual(c.cols, 5); assert.strictEqual(c.events.length, 2);
114
115c = VT.parseCast('{"version":3,"term":{"cols":10,"rows":3}}\n[1.0,"o","a"]\n');
116assert.strictEqual(VT.appendCast(c, '[0.5,"o",'), 0);
117assert.strictEqual(c.duration, 1);
118assert.strictEqual(VT.appendCast(c, '"b"]\n[0.5,"m","x"]\n'), 2);
119assert.strictEqual(c.duration, 2);
120assert.strictEqual(c.events.length, 3);
121assert.strictEqual(c.events[1].t, 1.5);
122
123c = VT.parseCast('{"version":2,"width":80,"height":24}\n[1.0,"o","a"]\n');
124VT.appendCast(c, '# noise\n{"version":2}\n[3.0,"o","b"]\n');
125assert.strictEqual(c.duration, 3); assert.strictEqual(c.events[1].t, 3);
126
127c = VT.parseCast('{"version":3,"term":{"cols":4,"rows":1}}\n[1.0,"o","hi"]\n[2.0,"o');
128assert.strictEqual(c.events.length, 1);
129VT.appendCast(c, '","yo"]\n');
130assert.strictEqual(c.events.length, 2); assert.strictEqual(c.duration, 3);
131
132c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"]]}');
133assert.strictEqual(VT.appendCast(c, '[1.0,"o","y"]\n'), 0);
134
135const ev = [{t:1},{t:2}];
136const pacing = VT.buildPacing(ev, 2, null);
137ev.push({t:10});
138VT.extendPacing(pacing, ev, 2, 10);
139assert.strictEqual(pacing.pacedDuration, 10);
140assert.strictEqual(VT.mapTime(pacing.rec, pacing.paced, 10), 10);
141const limited = VT.buildPacing([{t:1}], 1, 2);
142VT.extendPacing(limited, [{t:1},{t:9}], 1, 9);
143assert.strictEqual(limited.pacedDuration, 3);
144assert.strictEqual(VT.mapTime(limited.paced, limited.rec, 3), 9);
145
146let t = new VT.Term(10, 3);
147t.write('hello\r\nworld');
148assert.deepStrictEqual(t.textLines(), ['hello', 'world', '']);
149t.write('\x1b[1;3Hga');
150assert.strictEqual(t.textLines()[0], 'hegao');
151t.write('\x1b[2J');
152assert.deepStrictEqual(t.textLines(), ['', '', '']);
153
154t = new VT.Term(10, 1);
155t.write('\x1b[31mred\x1b[0m ok');
156const runs = t.snapshot().rows[0];
157assert.strictEqual(runs[0].text, 'red'); assert.strictEqual(runs[0].fg, 1);
158t = new VT.Term(4, 1);
159t.write('\x1b[38;5;196mX\x1b[38;2;1;2;3mY');
160assert.strictEqual(t.snapshot().rows[0][0].fg, 196);
161assert.strictEqual(t.snapshot().rows[0][1].fg, '#010203');
162
163t = new VT.Term(3, 2);
164t.write('abc');
165assert.strictEqual(t.snapshot().cursor.y, 0);
166t.write('d');
167assert.deepStrictEqual(t.textLines(), ['abc', 'd']);
168
169t = new VT.Term(5, 4);
170t.write('aa\r\nbb\r\ncc\r\ndd');
171t.write('\x1b[2;3r\x1b[3;1H\n');
172assert.strictEqual(t.textLines()[0], 'aa');
173assert.strictEqual(t.textLines()[1], 'cc');
174assert.strictEqual(t.textLines()[3], 'dd');
175
176t = new VT.Term(5, 2);
177t.write('main');
178t.write('\x1b[?1049h\x1b[Halt');
179assert.strictEqual(t.textLines()[0], 'alt');
180t.write('\x1b[?1049l');
181assert.strictEqual(t.textLines()[0], 'main');
182
183t = new VT.Term(4, 1);
184t.write('\x1b(0qqx\x1b(B');
185assert.strictEqual(t.textLines()[0], '──│');
186t = new VT.Term(8, 1);
187t.write('\x1b]0;title\x07ok');
188assert.strictEqual(t.textLines()[0], 'ok');
189t.write('\x1b[?25l');
190assert.strictEqual(t.snapshot().cursor.visible, false);
191
192// ---- Phase 1: headless controller (no DOM) ---------------------------------------------
193function fakeClock() {
194 let now = 0;
195 const q = [];
196 return {
197 now: () => now,
198 requestAnimationFrame: (cb) => { q.push(cb); return q.length; },
199 cancelAnimationFrame: () => { q.length = 0; },
200 flush: (ms) => {
201 now += ms;
202 const batch = q.splice(0, q.length);
203 for (const cb of batch) cb(now);
204 },
205 };
206}
207
208const castText = '{"version":3,"term":{"cols":8,"rows":2}}\n[0,"o","hi"]\n[1.0,"o","!"]\n[1.0,"m","mid"]\n';
209const clock = fakeClock();
210const ctrl = C.create({
211 data: castText,
212 idleTimeLimit: 2,
213 speed: 1,
214 markers: [[0, 'start']],
215 clock: clock,
216});
217
218// Initial state
219let state = ctrl.getState();
220assert.strictEqual(state.status, 'idle');
221assert.strictEqual(state.currentTime, 0);
222assert.strictEqual(state.duration, 2);
223assert.strictEqual(state.speed, 1);
224assert.strictEqual(state.canAppend, true);
225assert.ok(state.markers.length >= 2); // sidecar + in-band
226assert.ok(state.markers.every(m => m.id && typeof m.time === 'number' && m.label != null));
227assert.ok(state.terminal.rows.length === 2);
228assert.strictEqual(state.dimensions.columns, 8);
229
230// subscribe delivers immediately and is removable
231const seen = [];
232const unsub = ctrl.subscribe((s, meta) => { seen.push(meta.type); });
233assert.ok(seen.includes('ready'));
234unsub();
235unsub(); // idempotent
236const n = seen.length;
237ctrl.setSpeed(2);
238// no more callbacks after unsubscribe
239assert.strictEqual(seen.length, n);
240
241// setSpeed does not rebuild terminal / changes state
242ctrl.setSpeed(1.5);
243assert.strictEqual(ctrl.getState().speed, 1.5);
244
245// play / pause / seek / getCurrentTime
246// First animation frame establishes lastTick (dt=0); the second advances the clock.
247ctrl.play();
248assert.strictEqual(ctrl.getState().status, 'playing');
249clock.flush(0);
250clock.flush(500); // 0.5s wall * 1.5 speed ≈ 0.75s paced
251assert.ok(ctrl.getCurrentTime() > 0);
252ctrl.pause();
253assert.strictEqual(ctrl.getState().status, 'paused');
254
255ctrl.seek(1.5);
256assert.ok(Math.abs(ctrl.getCurrentTime() - 1.5) < 1e-9);
257ctrl.seek(0);
258assert.ok(ctrl.getCurrentTime() < 1e-9);
259
260// toggle
261ctrl.toggle();
262assert.strictEqual(ctrl.getState().status, 'playing');
263ctrl.toggle();
264assert.strictEqual(ctrl.getState().status, 'paused');
265
266// replay after end
267ctrl.seek(2);
268ctrl.play();
269// playing from end rewinds
270assert.strictEqual(ctrl.getState().status, 'playing');
271assert.ok(ctrl.getCurrentTime() < 1e-6);
272
273// append live-follow at edge
274ctrl.pause();
275ctrl.seek(2);
276assert.ok(ctrl.getState().atLiveEdge);
277ctrl.append('[0.5,"o","more"]\n');
278assert.strictEqual(ctrl.getState().duration, 2.5);
279
280// Declared-live mode: parked mid-recording, setLive pins the playhead to the edge; an
281// append keeps it pinned (unconditionally — no positional check); a rewinding seek or
282// play() drops live; a seek TO the edge keeps it.
283ctrl.seek(1);
284ctrl.setLive(true);
285let live = ctrl.getState();
286assert.strictEqual(live.live, true);
287assert.ok(live.atLiveEdge, 'setLive parks at the edge');
288assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9);
289ctrl.append('[0.5,"o","live"]\n');
290live = ctrl.getState();
291assert.strictEqual(live.live, true);
292assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9, 'append keeps the pin');
293ctrl.seek(live.duration); // to the edge: still live
294assert.strictEqual(ctrl.getState().live, true);
295const liveEvents = [];
296const unsubLive = ctrl.subscribe((st, meta) => { liveEvents.push(meta.type); });
297ctrl.seek(0.5); // a rewind: live drops, with a livechange event
298assert.strictEqual(ctrl.getState().live, false);
299assert.ok(liveEvents.includes('livechange'));
300unsubLive();
301ctrl.setLive(true);
302ctrl.play(); // play from the parked edge is a rewind: live drops
303assert.strictEqual(ctrl.getState().live, false);
304ctrl.pause();
305
306// getState is a snapshot (mutating returned markers does not corrupt internal list)
307const s1 = ctrl.getState();
308s1.markers.push({ id: 'x', time: 99, type: 'chapter', label: 'x' });
309assert.ok(ctrl.getState().markers.every(m => m.id !== 'x'));
310
311// dispose makes commands no-ops
312ctrl.dispose();
313ctrl.play();
314ctrl.seek(1);
315ctrl.append('[1,"o","z"]\n');
316assert.strictEqual(ctrl.getState().status, 'idle');
317
318// Marker object form + tuple compatibility
319const c2 = C.create({
320 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n',
321 markers: [{ time: 0, label: 'A', type: 'chapter' }, [1, 'B']],
322 clock: fakeClock(),
323});
324const marks = c2.getState().markers;
325assert.strictEqual(marks[0].label, 'A');
326assert.strictEqual(marks[1].label, 'B');
327c2.dispose();
328
329// Source type text
330const c3 = C.create({
331 source: { type: 'text', data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","z"]\n' },
332 clock: fakeClock(),
333});
334assert.strictEqual(c3.getState().duration, 0);
335c3.dispose();
336
337// Discrete events survive playback: seek/speedchange/durationchange/markerchange emitted
338// WHILE PLAYING must each reach subscribers with their own meta type — timeupdate
339// coalescing must never swallow them.
340const clock4 = fakeClock();
341const c4 = C.create({ data: castText, idleTimeLimit: 2, clock: clock4 });
342const types = [];
343c4.subscribe(function (s, meta) { types.push(meta.type); });
344c4.play();
345clock4.flush(0);
346clock4.flush(200);
347assert.ok(types.includes('timeupdate'), 'timeupdate flows while playing');
348assert.strictEqual(c4.getState().status, 'playing');
349c4.seek(1.5, { origin: 'api' });
350assert.ok(types.includes('seek'), 'seek delivered while playing, saw: ' + types.join(','));
351c4.append('[5,"o","tail"]\n');
352assert.ok(types.includes('durationchange'), 'durationchange delivered while playing');
353c4.setSpeed(2);
354assert.ok(types.includes('speedchange'), 'speedchange delivered while playing');
355c4.setMarkers([[0.5, 'half']]);
356assert.ok(types.includes('markerchange'), 'markerchange delivered');
357assert.strictEqual(c4.getState().markers.filter(m => m.source === 'integration').length, 1);
358c4.dispose();
359
360console.log('vt selftest OK');
361"#;
362 let spawned = std::process::Command::new("node")
363 .arg("-")
364 .arg(&bundle)
365 .stdin(std::process::Stdio::piped())
366 .stdout(std::process::Stdio::piped())
367 .stderr(std::process::Stdio::piped())
368 .spawn();
369 let Ok(mut child) = spawned else {
370 let _ = std::fs::remove_dir_all(&dir);
371 return; };
373 use std::io::Write;
374 child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
375 let out = child.wait_with_output().unwrap();
376 let _ = std::fs::remove_dir_all(&dir);
377 assert!(
378 out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
379 "vt selftest failed:\nstdout: {}\nstderr: {}",
380 String::from_utf8_lossy(&out.stdout),
381 String::from_utf8_lossy(&out.stderr)
382 );
383 }
384}