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