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// Long recordings keep real time after a seek unless the embedder explicitly opts into
333// idle compression. A page-level default must not turn a sparse 20-minute interval into
334// an uncontrollable two-second sprint.
335const longClock = fakeClock();
336const longCtrl = C.create({
337 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n[1200,"o","b"]\n',
338 clock: longClock,
339});
340longCtrl.seek(600);
341longCtrl.play();
342longClock.flush(0);
343longClock.flush(1000);
344assert.ok(Math.abs(longCtrl.getCurrentTime() - 601) < 1e-9);
345assert.strictEqual(longCtrl.getState().status, 'playing');
346longCtrl.dispose();
347
348// toggle
349ctrl.toggle();
350assert.strictEqual(ctrl.getState().status, 'playing');
351ctrl.toggle();
352assert.strictEqual(ctrl.getState().status, 'paused');
353
354// replay after end
355ctrl.seek(2);
356ctrl.play();
357// playing from end rewinds
358assert.strictEqual(ctrl.getState().status, 'playing');
359assert.ok(ctrl.getCurrentTime() < 1e-6);
360
361// append live-follow at edge
362ctrl.pause();
363ctrl.seek(2);
364assert.ok(ctrl.getState().atLiveEdge);
365ctrl.append('[0.5,"o","more"]\n');
366assert.strictEqual(ctrl.getState().duration, 2.5);
367
368// Declared-live mode: parked mid-recording, setLive pins the playhead to the edge; an
369// append keeps it pinned (unconditionally — no positional check); a rewinding seek or
370// play() drops live; a seek TO the edge keeps it.
371ctrl.seek(1);
372ctrl.setLive(true);
373let live = ctrl.getState();
374assert.strictEqual(live.live, true);
375assert.ok(live.atLiveEdge, 'setLive parks at the edge');
376assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9);
377ctrl.append('[0.5,"o","live"]\n');
378live = ctrl.getState();
379assert.strictEqual(live.live, true);
380assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9, 'append keeps the pin');
381ctrl.seek(live.duration); // to the edge: still live
382assert.strictEqual(ctrl.getState().live, true);
383const liveEvents = [];
384const unsubLive = ctrl.subscribe((st, meta) => { liveEvents.push(meta.type); });
385ctrl.seek(0.5); // a rewind: live drops, with a livechange event
386assert.strictEqual(ctrl.getState().live, false);
387assert.ok(liveEvents.includes('livechange'));
388unsubLive();
389ctrl.setLive(true);
390ctrl.play(); // play from the parked edge is a rewind: live drops
391assert.strictEqual(ctrl.getState().live, false);
392// Catching back up to the current appended edge automatically resumes Live mode.
393clock.flush(0);
394clock.flush(3000);
395assert.strictEqual(ctrl.getState().status, 'ended');
396assert.strictEqual(ctrl.getState().live, true);
397
398// Explicitly leaving Live mode opts out of the automatic return.
399ctrl.setLive(false);
400ctrl.play();
401clock.flush(0);
402clock.flush(3000);
403assert.strictEqual(ctrl.getState().live, false);
404
405// getState is a snapshot (mutating returned markers does not corrupt internal list)
406const s1 = ctrl.getState();
407s1.markers.push({ id: 'x', time: 99, type: 'chapter', label: 'x' });
408assert.ok(ctrl.getState().markers.every(m => m.id !== 'x'));
409
410// dispose makes commands no-ops
411ctrl.dispose();
412ctrl.play();
413ctrl.seek(1);
414ctrl.append('[1,"o","z"]\n');
415assert.strictEqual(ctrl.getState().status, 'idle');
416
417// Marker object form + tuple compatibility
418const c2 = C.create({
419 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n',
420 markers: [{ time: 0, label: 'A', type: 'chapter' }, [1, 'B']],
421 clock: fakeClock(),
422});
423const marks = c2.getState().markers;
424assert.strictEqual(marks[0].label, 'A');
425assert.strictEqual(marks[1].label, 'B');
426c2.dispose();
427
428// Source type text
429const c3 = C.create({
430 source: { type: 'text', data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","z"]\n' },
431 clock: fakeClock(),
432});
433assert.strictEqual(c3.getState().duration, 0);
434c3.dispose();
435
436// Discrete events survive playback: seek/speedchange/durationchange/markerchange emitted
437// WHILE PLAYING must each reach subscribers with their own meta type — timeupdate
438// coalescing must never swallow them.
439const clock4 = fakeClock();
440const c4 = C.create({ data: castText, idleTimeLimit: 2, clock: clock4 });
441const types = [];
442c4.subscribe(function (s, meta) { types.push(meta.type); });
443c4.play();
444clock4.flush(0);
445clock4.flush(200);
446assert.ok(types.includes('timeupdate'), 'timeupdate flows while playing');
447assert.strictEqual(c4.getState().status, 'playing');
448c4.seek(1.5, { origin: 'api' });
449assert.ok(types.includes('seek'), 'seek delivered while playing, saw: ' + types.join(','));
450c4.append('[5,"o","tail"]\n');
451assert.ok(types.includes('durationchange'), 'durationchange delivered while playing');
452c4.setSpeed(2);
453assert.ok(types.includes('speedchange'), 'speedchange delivered while playing');
454c4.setMarkers([[0.5, 'half']]);
455assert.ok(types.includes('markerchange'), 'markerchange delivered');
456assert.strictEqual(c4.getState().markers.filter(m => m.source === 'integration').length, 1);
457c4.dispose();
458
459// ---- marker jumps seek without autoplay ----------------------------------------------
460const clockM = fakeClock();
461const cM = C.create({
462 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n[1.0,"o","b"]\n[2.0,"o","c"]\n',
463 markers: [[0, 'start'], [1, 'mid'], [2, 'end']],
464 clock: clockM,
465});
466cM.play();
467clockM.flush(0);
468cM.pause();
469assert.strictEqual(cM.getState().status, 'paused');
470cM.seek(0);
471cM.jumpMarker(1, 'keyboard');
472assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9, ' ] seeks to next marker');
473assert.strictEqual(cM.getState().status, 'paused', '[ ] must not autoplay');
474cM.play();
475assert.strictEqual(cM.getState().status, 'playing');
476cM.jumpMarker(1, 'keyboard');
477assert.ok(Math.abs(cM.getCurrentTime() - 2) < 1e-9);
478assert.strictEqual(cM.getState().status, 'playing', 'jump while playing stays playing');
479cM.pause();
480cM.jumpMarker(-1, 'keyboard');
481assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9);
482assert.strictEqual(cM.getState().status, 'paused', '[ while paused stays paused');
483cM.dispose();
484
485// ---- fit:'both' scale rule: content-sized mounts must not ratchet -----------------
486// Mirrors layout()'s definite-height gate (vertical fit only when the mount's height
487// does not track the screen box). The old `availH - 4` trigger shrank forever on
488// content-sized embeds.
489function fitScale(naturalH, availH, definite) {
490 let scale = 1;
491 if (definite && availH > 40 && naturalH * scale > availH) {
492 scale = Math.min(scale, availH / naturalH);
493 }
494 return scale;
495}
496let contentH = 400;
497const natural = 400, bar = 32;
498for (let i = 0; i < 30; i++) {
499 // Content-sized: mount height tracks the box — vertical fit must stay off.
500 const s = fitScale(natural, contentH - bar, false);
501 assert.strictEqual(s, 1, 'content-sized mount must not vertically scale');
502 contentH = natural * s + bar;
503}
504const short = fitScale(400, 200 - 32, true);
505assert.ok(short < 1 && short > 0, 'definite short mount scales down once');
506assert.strictEqual(fitScale(400, 200 - 32, true), short, 'definite scale is stable');
507// The banned ratchet: subtracting 4 from availH forces another shrink every pass.
508let ratchetH = 400;
509for (let i = 0; i < 5; i++) {
510 const avail = ratchetH - bar;
511 if (natural > avail - 4) ratchetH = natural * ((avail - 4) / natural) + bar;
512}
513assert.ok(ratchetH < 390, 'sanity: the old availH-4 rule really does ratchet');
514
515console.log('vt selftest OK');
516"#;
517 let spawned = std::process::Command::new("node")
518 .arg("-")
519 .arg(&bundle)
520 .stdin(std::process::Stdio::piped())
521 .stdout(std::process::Stdio::piped())
522 .stderr(std::process::Stdio::piped())
523 .spawn();
524 let Ok(mut child) = spawned else {
525 let _ = std::fs::remove_dir_all(&dir);
526 return; };
528 use std::io::Write;
529 child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
530 let out = child.wait_with_output().unwrap();
531 let _ = std::fs::remove_dir_all(&dir);
532 assert!(
533 out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
534 "vt selftest failed:\nstdout: {}\nstderr: {}",
535 String::from_utf8_lossy(&out.stdout),
536 String::from_utf8_lossy(&out.stderr)
537 );
538 }
539}