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