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