Skip to main content

beecast_player/
lib.rs

1//! The first-party beecast player as a crate: clean-room, dependency-free
2//! asciicast (v1/v2/v3) player and VT100/xterm-subset terminal emulator. Consumers inline
3//! the two constants whole — a `<script>` element and a `<style>` element — and get the
4//! full player: parsing, emulation, headless controller, default controls, Web Component,
5//! chapter markers, keyboard control, and live-follow `append` for recordings that are
6//! still growing.
7//!
8//! The JS globals are `BeeCastVT` (DOM-free core), `BeeCastController` (headless playback),
9//! and `BeeCastPlayer` (DOM factory + `<beecast-player>`); this crate is the component's
10//! canonical home.
11
12/// The player bundle: `vt.js` (core) + `controller.js` (headless playback) + `player.js`
13/// (DOM view, Web Component, legacy factory). Inline it in one `<script>` element; it
14/// performs no network requests and loads no workers.
15pub const PLAYER_JS: &str =
16  concat!(include_str!("vt.js"), "\n", include_str!("controller.js"), "\n", include_str!("player.js"),);
17
18/// The player chrome and terminal palette. Semantic `--beecast-*` tokens are the stable
19/// theming surface; `--sp-*` is the terminal palette. Nothing is fetched (no fonts, no images).
20pub const PLAYER_CSS: &str = include_str!("player.css");
21
22#[cfg(test)]
23mod tests {
24  use super::*;
25
26  /// Every embedder's self-containment claim depends on the bundle itself: it must not
27  /// contain a literal `</script`, must not reference workers, and its CSS must not pull
28  /// fonts or images. Asserted here so a player change that breaks any of it fails loudly.
29  /// And the bundle must be the first-party clean-room player — no third-party code, and
30  /// therefore no third-party license, rides into any embedding page.
31  #[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  /// The center play overlay must appear whenever playback is not running (and not in
46  /// declared-live mode), and marker jumps must seek without calling play.
47  #[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    // jumpMarker used to end with play(origin); chapter rows did too.
55    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("'|&gt;'") && !PLAYER_JS.contains("\"|&gt;\""),
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  /// `fit: 'both'` must never vertically scale against a content-sized mount: that path
75  /// was a ResizeObserver shrink ratchet (scsh's live dashboard). The definite-height
76  /// probe and the absence of the old `availH - 4` trigger pin the fix in the bundle.
77  #[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  /// Phase 0: the public surface must not shrink without an intentional change.
98  #[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  /// Behavior tests for the DOM-free core and headless controller, run under Node.
127  /// Skips silently when `node` is not installed — the structural assertions above still gate the bundle.
128  #[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; // no node on this machine — the structural tests above still ran
495    };
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}