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]
48 fn overlay_and_marker_jumps_do_not_autoplay() {
49 assert!(
50 PLAYER_JS.contains("!state.live && state.status !== 'playing' && state.duration > 0"),
51 "overlay must show when not playing and not live, not only at t = 0"
52 );
53 assert!(!PLAYER_JS.contains("currentTime <= 1e-9 && state.duration"), "the t≈0-only overlay gate must stay gone");
54 assert!(
56 !PLAYER_JS.contains("this.play(origin || 'marker')") && !PLAYER_JS.contains("self.play('marker')"),
57 "marker/chapter navigation must not force play"
58 );
59 assert!(
60 PLAYER_JS.contains("sp-bigplay-icon") && PLAYER_JS.contains("viewBox=\"0 0 80 80\""),
61 "play overlay must be an equal-height SVG |> (bar + chevron), not monospace text"
62 );
63 assert!(
64 !PLAYER_JS.contains("'|>'") && !PLAYER_JS.contains("\"|>\""),
65 "monospace |> text glyph must stay gone"
66 );
67 assert!(!PLAYER_JS.contains("▄█"), "the block-character triangle glyph must stay gone");
68 assert!(
69 PLAYER_JS.contains("sp-live") && PLAYER_CSS.contains(".sp-live"),
70 "Live control lives in the player toolbar when controls.live is enabled"
71 );
72 }
73
74 #[test]
78 fn fit_both_refuses_the_content_sized_shrink_ratchet() {
79 assert!(
80 PLAYER_JS.contains("mountHeightIsDefinite"),
81 "layout must probe whether the mount's height is independent of the screen box"
82 );
83 assert!(
84 PLAYER_JS.contains("before > 0 && before === after"),
85 "definite-height probe must compare mount clientHeight before/after collapsing the box"
86 );
87 assert!(
88 !PLAYER_JS.contains("availH - 4"),
89 "the availH-4 trigger forced another shrink on every ResizeObserver tick"
90 );
91 assert!(
92 PLAYER_JS.contains("mount && mount.clientHeight > 0") && PLAYER_JS.contains("wrapFs"),
93 "wrap-fullscreen layout must prefer the mount height over the outer fullscreen host"
94 );
95 assert!(
96 PLAYER_JS.contains("sp-stage") && PLAYER_CSS.contains(".sp-stage"),
97 "fullscreen centering lives in a flex stage above the control bar"
98 );
99 assert!(!PLAYER_JS.contains("canDockChapters"), "chapter overlays must never consume terminal layout space");
100 }
101
102 #[test]
104 fn public_api_surface_is_documented() {
105 for key in [
106 "BeeCastVT",
107 "BeeCastController",
108 "BeeCastPlayer",
109 "parseCast",
110 "appendCast",
111 "buildPacing",
112 "extendPacing",
113 "mapTime",
114 "create",
115 "getState",
116 "subscribe",
117 "setSpeed",
118 "publicMethods",
119 "supportedCssVariables",
120 "nonPublicFields",
121 ] {
122 assert!(PLAYER_JS.contains(key), "public surface missing {key}");
123 }
124 for token in
125 ["--beecast-color-surface", "--beecast-color-accent", "--beecast-color-focus", "--beecast-font-terminal"]
126 {
127 assert!(PLAYER_CSS.contains(token), "theme token missing {token}");
128 }
129 }
130
131 #[test]
134 fn vt_core_node_selftest() {
135 let dir = std::env::temp_dir().join(format!("beecast-vt-selftest-{}", std::process::id()));
136 std::fs::create_dir_all(&dir).unwrap();
137 let bundle = dir.join("player.js");
138 std::fs::write(&bundle, PLAYER_JS).unwrap();
139 let script = r#"
140const assert = require('assert');
141require(process.argv[2]);
142const VT = globalThis.BeeCastVT;
143const C = globalThis.BeeCastController;
144const P = globalThis.BeeCastPlayer;
145
146// ---- Phase 0: public keys --------------------------------------------------------------
147for (const k of ['parseCast','appendCast','buildPacing','extendPacing','mapTime','Term']) {
148 assert.strictEqual(typeof VT[k], 'function', 'BeeCastVT.' + k);
149}
150assert.strictEqual(typeof C.create, 'function');
151assert.strictEqual(typeof P.create, 'function');
152assert.ok(Array.isArray(P.publicMethods));
153for (const m of ['create','play','pause','toggle','seek','getCurrentTime','append','dispose']) {
154 assert.ok(P.publicMethods.includes(m), 'publicMethods missing ' + m);
155}
156assert.ok(P.nonPublicFields.includes('playing'));
157assert.ok(P.nonPublicFields.includes('pacedPos'));
158assert.ok(P.supportedCssVariables.includes('--beecast-color-accent'));
159
160// ---- VT core (existing coverage) -------------------------------------------------------
161let 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');
162assert.strictEqual(c.cols, 10); assert.strictEqual(c.rows, 3);
163assert.strictEqual(c.events.length, 3);
164assert.strictEqual(c.duration, 2);
165
166c = VT.parseCast('{"version":2,"width":80,"height":24}\n[0.5,"o","a"]\n[2.0,"o","b"]\n');
167assert.strictEqual(c.duration, 2); assert.strictEqual(c.events[1].t, 2);
168
169c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"],[0.2,"y"]]}');
170assert.strictEqual(c.cols, 5); assert.strictEqual(c.events.length, 2);
171
172c = VT.parseCast('{"version":3,"term":{"cols":10,"rows":3}}\n[1.0,"o","a"]\n');
173assert.strictEqual(VT.appendCast(c, '[0.5,"o",'), 0);
174assert.strictEqual(c.duration, 1);
175assert.strictEqual(VT.appendCast(c, '"b"]\n[0.5,"m","x"]\n'), 2);
176assert.strictEqual(c.duration, 2);
177assert.strictEqual(c.events.length, 3);
178assert.strictEqual(c.events[1].t, 1.5);
179
180c = VT.parseCast('{"version":2,"width":80,"height":24}\n[1.0,"o","a"]\n');
181VT.appendCast(c, '# noise\n{"version":2}\n[3.0,"o","b"]\n');
182assert.strictEqual(c.duration, 3); assert.strictEqual(c.events[1].t, 3);
183
184c = VT.parseCast('{"version":3,"term":{"cols":4,"rows":1}}\n[1.0,"o","hi"]\n[2.0,"o');
185assert.strictEqual(c.events.length, 1);
186VT.appendCast(c, '","yo"]\n');
187assert.strictEqual(c.events.length, 2); assert.strictEqual(c.duration, 3);
188
189c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"]]}');
190assert.strictEqual(VT.appendCast(c, '[1.0,"o","y"]\n'), 0);
191
192const ev = [{t:1},{t:2}];
193const pacing = VT.buildPacing(ev, 2, null);
194ev.push({t:10});
195VT.extendPacing(pacing, ev, 2, 10);
196assert.strictEqual(pacing.pacedDuration, 10);
197assert.strictEqual(VT.mapTime(pacing.rec, pacing.paced, 10), 10);
198const limited = VT.buildPacing([{t:1}], 1, 2);
199VT.extendPacing(limited, [{t:1},{t:9}], 1, 9);
200assert.strictEqual(limited.pacedDuration, 3);
201assert.strictEqual(VT.mapTime(limited.paced, limited.rec, 3), 9);
202
203let t = new VT.Term(10, 3);
204t.write('hello\r\nworld');
205assert.deepStrictEqual(t.textLines(), ['hello', 'world', '']);
206t.write('\x1b[1;3Hga');
207assert.strictEqual(t.textLines()[0], 'hegao');
208t.write('\x1b[2J');
209assert.deepStrictEqual(t.textLines(), ['', '', '']);
210
211t = new VT.Term(10, 1);
212t.write('\x1b[31mred\x1b[0m ok');
213const runs = t.snapshot().rows[0];
214assert.strictEqual(runs[0].text, 'red'); assert.strictEqual(runs[0].fg, 1);
215t = new VT.Term(4, 1);
216t.write('\x1b[38;5;196mX\x1b[38;2;1;2;3mY');
217assert.strictEqual(t.snapshot().rows[0][0].fg, 196);
218assert.strictEqual(t.snapshot().rows[0][1].fg, '#010203');
219
220t = new VT.Term(3, 2);
221t.write('abc');
222assert.strictEqual(t.snapshot().cursor.y, 0);
223t.write('d');
224assert.deepStrictEqual(t.textLines(), ['abc', 'd']);
225
226t = new VT.Term(5, 4);
227t.write('aa\r\nbb\r\ncc\r\ndd');
228t.write('\x1b[2;3r\x1b[3;1H\n');
229assert.strictEqual(t.textLines()[0], 'aa');
230assert.strictEqual(t.textLines()[1], 'cc');
231assert.strictEqual(t.textLines()[3], 'dd');
232
233t = new VT.Term(5, 2);
234t.write('main');
235t.write('\x1b[?1049h\x1b[Halt');
236assert.strictEqual(t.textLines()[0], 'alt');
237t.write('\x1b[?1049l');
238assert.strictEqual(t.textLines()[0], 'main');
239
240t = new VT.Term(4, 1);
241t.write('\x1b(0qqx\x1b(B');
242assert.strictEqual(t.textLines()[0], '──│');
243t = new VT.Term(8, 1);
244t.write('\x1b]0;title\x07ok');
245assert.strictEqual(t.textLines()[0], 'ok');
246t.write('\x1b[?25l');
247assert.strictEqual(t.snapshot().cursor.visible, false);
248
249// ---- Phase 1: headless controller (no DOM) ---------------------------------------------
250function fakeClock() {
251 let now = 0;
252 const q = [];
253 return {
254 now: () => now,
255 requestAnimationFrame: (cb) => { q.push(cb); return q.length; },
256 cancelAnimationFrame: () => { q.length = 0; },
257 flush: (ms) => {
258 now += ms;
259 const batch = q.splice(0, q.length);
260 for (const cb of batch) cb(now);
261 },
262 };
263}
264
265const castText = '{"version":3,"term":{"cols":8,"rows":2}}\n[0,"o","hi"]\n[1.0,"o","!"]\n[1.0,"m","mid"]\n';
266const clock = fakeClock();
267const ctrl = C.create({
268 data: castText,
269 idleTimeLimit: 2,
270 speed: 1,
271 markers: [[0, 'start']],
272 clock: clock,
273});
274
275// Initial state
276let state = ctrl.getState();
277assert.strictEqual(state.status, 'idle');
278assert.strictEqual(state.currentTime, 0);
279assert.strictEqual(state.duration, 2);
280assert.strictEqual(state.speed, 1);
281assert.strictEqual(state.canAppend, true);
282assert.ok(state.markers.length >= 2); // sidecar + in-band
283assert.ok(state.markers.every(m => m.id && typeof m.time === 'number' && m.label != null));
284assert.ok(state.terminal.rows.length === 2);
285assert.strictEqual(state.dimensions.columns, 8);
286
287// Terminal snapshots are cached until output or resize dirties the terminal.
288let snapshotCalls = 0;
289const originalSnapshot = ctrl.term.snapshot;
290ctrl.term.snapshot = function () { snapshotCalls++; return originalSnapshot.call(this); };
291const cachedTerminal = ctrl.getState().terminal;
292assert.strictEqual(ctrl.getState().terminal, cachedTerminal);
293assert.strictEqual(snapshotCalls, 0, 'initial snapshot remains cached');
294ctrl.seek(1.5);
295const changedTerminal = ctrl.getState().terminal;
296assert.notStrictEqual(changedTerminal, cachedTerminal);
297assert.strictEqual(snapshotCalls, 1, 'events invalidate the cached snapshot once');
298assert.strictEqual(ctrl.getState().terminal, changedTerminal);
299assert.strictEqual(snapshotCalls, 1, 'unchanged state does not copy the terminal again');
300ctrl.seek(0);
301
302// subscribe delivers immediately and is removable
303const seen = [];
304const unsub = ctrl.subscribe((s, meta) => { seen.push(meta.type); });
305assert.ok(seen.includes('ready'));
306unsub();
307unsub(); // idempotent
308const n = seen.length;
309ctrl.setSpeed(2);
310// no more callbacks after unsubscribe
311assert.strictEqual(seen.length, n);
312
313// setSpeed does not rebuild terminal / changes state
314ctrl.setSpeed(1.5);
315assert.strictEqual(ctrl.getState().speed, 1.5);
316
317// play / pause / seek / getCurrentTime
318// First animation frame establishes lastTick (dt=0); the second advances the clock.
319ctrl.play();
320assert.strictEqual(ctrl.getState().status, 'playing');
321clock.flush(0);
322clock.flush(500); // 0.5s wall * 1.5 speed ≈ 0.75s paced
323assert.ok(ctrl.getCurrentTime() > 0);
324ctrl.pause();
325assert.strictEqual(ctrl.getState().status, 'paused');
326
327ctrl.seek(1.5);
328assert.ok(Math.abs(ctrl.getCurrentTime() - 1.5) < 1e-9);
329ctrl.seek(0);
330assert.ok(ctrl.getCurrentTime() < 1e-9);
331
332// toggle
333ctrl.toggle();
334assert.strictEqual(ctrl.getState().status, 'playing');
335ctrl.toggle();
336assert.strictEqual(ctrl.getState().status, 'paused');
337
338// replay after end
339ctrl.seek(2);
340ctrl.play();
341// playing from end rewinds
342assert.strictEqual(ctrl.getState().status, 'playing');
343assert.ok(ctrl.getCurrentTime() < 1e-6);
344
345// append live-follow at edge
346ctrl.pause();
347ctrl.seek(2);
348assert.ok(ctrl.getState().atLiveEdge);
349ctrl.append('[0.5,"o","more"]\n');
350assert.strictEqual(ctrl.getState().duration, 2.5);
351
352// Declared-live mode: parked mid-recording, setLive pins the playhead to the edge; an
353// append keeps it pinned (unconditionally — no positional check); a rewinding seek or
354// play() drops live; a seek TO the edge keeps it.
355ctrl.seek(1);
356ctrl.setLive(true);
357let live = ctrl.getState();
358assert.strictEqual(live.live, true);
359assert.ok(live.atLiveEdge, 'setLive parks at the edge');
360assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9);
361ctrl.append('[0.5,"o","live"]\n');
362live = ctrl.getState();
363assert.strictEqual(live.live, true);
364assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9, 'append keeps the pin');
365ctrl.seek(live.duration); // to the edge: still live
366assert.strictEqual(ctrl.getState().live, true);
367const liveEvents = [];
368const unsubLive = ctrl.subscribe((st, meta) => { liveEvents.push(meta.type); });
369ctrl.seek(0.5); // a rewind: live drops, with a livechange event
370assert.strictEqual(ctrl.getState().live, false);
371assert.ok(liveEvents.includes('livechange'));
372unsubLive();
373ctrl.setLive(true);
374ctrl.play(); // play from the parked edge is a rewind: live drops
375assert.strictEqual(ctrl.getState().live, false);
376// Catching back up to the current appended edge automatically resumes Live mode.
377clock.flush(0);
378clock.flush(3000);
379assert.strictEqual(ctrl.getState().status, 'ended');
380assert.strictEqual(ctrl.getState().live, true);
381
382// Explicitly leaving Live mode opts out of the automatic return.
383ctrl.setLive(false);
384ctrl.play();
385clock.flush(0);
386clock.flush(3000);
387assert.strictEqual(ctrl.getState().live, false);
388
389// getState is a snapshot (mutating returned markers does not corrupt internal list)
390const s1 = ctrl.getState();
391s1.markers.push({ id: 'x', time: 99, type: 'chapter', label: 'x' });
392assert.ok(ctrl.getState().markers.every(m => m.id !== 'x'));
393
394// dispose makes commands no-ops
395ctrl.dispose();
396ctrl.play();
397ctrl.seek(1);
398ctrl.append('[1,"o","z"]\n');
399assert.strictEqual(ctrl.getState().status, 'idle');
400
401// Marker object form + tuple compatibility
402const c2 = C.create({
403 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n',
404 markers: [{ time: 0, label: 'A', type: 'chapter' }, [1, 'B']],
405 clock: fakeClock(),
406});
407const marks = c2.getState().markers;
408assert.strictEqual(marks[0].label, 'A');
409assert.strictEqual(marks[1].label, 'B');
410c2.dispose();
411
412// Source type text
413const c3 = C.create({
414 source: { type: 'text', data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","z"]\n' },
415 clock: fakeClock(),
416});
417assert.strictEqual(c3.getState().duration, 0);
418c3.dispose();
419
420// Discrete events survive playback: seek/speedchange/durationchange/markerchange emitted
421// WHILE PLAYING must each reach subscribers with their own meta type — timeupdate
422// coalescing must never swallow them.
423const clock4 = fakeClock();
424const c4 = C.create({ data: castText, idleTimeLimit: 2, clock: clock4 });
425const types = [];
426c4.subscribe(function (s, meta) { types.push(meta.type); });
427c4.play();
428clock4.flush(0);
429clock4.flush(200);
430assert.ok(types.includes('timeupdate'), 'timeupdate flows while playing');
431assert.strictEqual(c4.getState().status, 'playing');
432c4.seek(1.5, { origin: 'api' });
433assert.ok(types.includes('seek'), 'seek delivered while playing, saw: ' + types.join(','));
434c4.append('[5,"o","tail"]\n');
435assert.ok(types.includes('durationchange'), 'durationchange delivered while playing');
436c4.setSpeed(2);
437assert.ok(types.includes('speedchange'), 'speedchange delivered while playing');
438c4.setMarkers([[0.5, 'half']]);
439assert.ok(types.includes('markerchange'), 'markerchange delivered');
440assert.strictEqual(c4.getState().markers.filter(m => m.source === 'integration').length, 1);
441c4.dispose();
442
443// ---- marker jumps seek without autoplay ----------------------------------------------
444const clockM = fakeClock();
445const cM = C.create({
446 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n[1.0,"o","b"]\n[2.0,"o","c"]\n',
447 markers: [[0, 'start'], [1, 'mid'], [2, 'end']],
448 clock: clockM,
449});
450cM.play();
451clockM.flush(0);
452cM.pause();
453assert.strictEqual(cM.getState().status, 'paused');
454cM.seek(0);
455cM.jumpMarker(1, 'keyboard');
456assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9, ' ] seeks to next marker');
457assert.strictEqual(cM.getState().status, 'paused', '[ ] must not autoplay');
458cM.play();
459assert.strictEqual(cM.getState().status, 'playing');
460cM.jumpMarker(1, 'keyboard');
461assert.ok(Math.abs(cM.getCurrentTime() - 2) < 1e-9);
462assert.strictEqual(cM.getState().status, 'playing', 'jump while playing stays playing');
463cM.pause();
464cM.jumpMarker(-1, 'keyboard');
465assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9);
466assert.strictEqual(cM.getState().status, 'paused', '[ while paused stays paused');
467cM.dispose();
468
469// ---- fit:'both' scale rule: content-sized mounts must not ratchet -----------------
470// Mirrors layout()'s definite-height gate (vertical fit only when the mount's height
471// does not track the screen box). The old `availH - 4` trigger shrank forever on
472// content-sized embeds.
473function fitScale(naturalH, availH, definite) {
474 let scale = 1;
475 if (definite && availH > 40 && naturalH * scale > availH) {
476 scale = Math.min(scale, availH / naturalH);
477 }
478 return scale;
479}
480let contentH = 400;
481const natural = 400, bar = 32;
482for (let i = 0; i < 30; i++) {
483 // Content-sized: mount height tracks the box — vertical fit must stay off.
484 const s = fitScale(natural, contentH - bar, false);
485 assert.strictEqual(s, 1, 'content-sized mount must not vertically scale');
486 contentH = natural * s + bar;
487}
488const short = fitScale(400, 200 - 32, true);
489assert.ok(short < 1 && short > 0, 'definite short mount scales down once');
490assert.strictEqual(fitScale(400, 200 - 32, true), short, 'definite scale is stable');
491// The banned ratchet: subtracting 4 from availH forces another shrink every pass.
492let ratchetH = 400;
493for (let i = 0; i < 5; i++) {
494 const avail = ratchetH - bar;
495 if (natural > avail - 4) ratchetH = natural * ((avail - 4) / natural) + bar;
496}
497assert.ok(ratchetH < 390, 'sanity: the old availH-4 rule really does ratchet');
498
499console.log('vt selftest OK');
500"#;
501 let spawned = std::process::Command::new("node")
502 .arg("-")
503 .arg(&bundle)
504 .stdin(std::process::Stdio::piped())
505 .stdout(std::process::Stdio::piped())
506 .stderr(std::process::Stdio::piped())
507 .spawn();
508 let Ok(mut child) = spawned else {
509 let _ = std::fs::remove_dir_all(&dir);
510 return; };
512 use std::io::Write;
513 child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
514 let out = child.wait_with_output().unwrap();
515 let _ = std::fs::remove_dir_all(&dir);
516 assert!(
517 out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
518 "vt selftest failed:\nstdout: {}\nstderr: {}",
519 String::from_utf8_lossy(&out.stdout),
520 String::from_utf8_lossy(&out.stderr)
521 );
522 }
523}