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    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  /// Phase 0: the public surface must not shrink without an intentional change.
103  #[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  /// Behavior tests for the DOM-free core and headless controller, run under Node.
132  /// Skips silently when `node` is not installed — the structural assertions above still gate the bundle.
133  #[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; // no node on this machine — the structural tests above still ran
527    };
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}