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// toggle
333ctrl.toggle();
334assert.strictEqual(ctrl.getState().status, 'playing');
335ctrl.toggle();
336assert.strictEqual(ctrl.getState().status, 'paused');
337
338// replay after end
339ctrl.seek(2);
340ctrl.play();
341// playing from end rewinds
342assert.strictEqual(ctrl.getState().status, 'playing');
343assert.ok(ctrl.getCurrentTime() < 1e-6);
344
345// append live-follow at edge
346ctrl.pause();
347ctrl.seek(2);
348assert.ok(ctrl.getState().atLiveEdge);
349ctrl.append('[0.5,"o","more"]\n');
350assert.strictEqual(ctrl.getState().duration, 2.5);
351
352// Declared-live mode: parked mid-recording, setLive pins the playhead to the edge; an
353// append keeps it pinned (unconditionally — no positional check); a rewinding seek or
354// play() drops live; a seek TO the edge keeps it.
355ctrl.seek(1);
356ctrl.setLive(true);
357let live = ctrl.getState();
358assert.strictEqual(live.live, true);
359assert.ok(live.atLiveEdge, 'setLive parks at the edge');
360assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9);
361ctrl.append('[0.5,"o","live"]\n');
362live = ctrl.getState();
363assert.strictEqual(live.live, true);
364assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9, 'append keeps the pin');
365ctrl.seek(live.duration); // to the edge: still live
366assert.strictEqual(ctrl.getState().live, true);
367const liveEvents = [];
368const unsubLive = ctrl.subscribe((st, meta) => { liveEvents.push(meta.type); });
369ctrl.seek(0.5); // a rewind: live drops, with a livechange event
370assert.strictEqual(ctrl.getState().live, false);
371assert.ok(liveEvents.includes('livechange'));
372unsubLive();
373ctrl.setLive(true);
374ctrl.play(); // play from the parked edge is a rewind: live drops
375assert.strictEqual(ctrl.getState().live, false);
376// Catching back up to the current appended edge automatically resumes Live mode.
377clock.flush(0);
378clock.flush(3000);
379assert.strictEqual(ctrl.getState().status, 'ended');
380assert.strictEqual(ctrl.getState().live, true);
381
382// Explicitly leaving Live mode opts out of the automatic return.
383ctrl.setLive(false);
384ctrl.play();
385clock.flush(0);
386clock.flush(3000);
387assert.strictEqual(ctrl.getState().live, false);
388
389// getState is a snapshot (mutating returned markers does not corrupt internal list)
390const s1 = ctrl.getState();
391s1.markers.push({ id: 'x', time: 99, type: 'chapter', label: 'x' });
392assert.ok(ctrl.getState().markers.every(m => m.id !== 'x'));
393
394// dispose makes commands no-ops
395ctrl.dispose();
396ctrl.play();
397ctrl.seek(1);
398ctrl.append('[1,"o","z"]\n');
399assert.strictEqual(ctrl.getState().status, 'idle');
400
401// Marker object form + tuple compatibility
402const c2 = C.create({
403  data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n',
404  markers: [{ time: 0, label: 'A', type: 'chapter' }, [1, 'B']],
405  clock: fakeClock(),
406});
407const marks = c2.getState().markers;
408assert.strictEqual(marks[0].label, 'A');
409assert.strictEqual(marks[1].label, 'B');
410c2.dispose();
411
412// Source type text
413const c3 = C.create({
414  source: { type: 'text', data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","z"]\n' },
415  clock: fakeClock(),
416});
417assert.strictEqual(c3.getState().duration, 0);
418c3.dispose();
419
420// Discrete events survive playback: seek/speedchange/durationchange/markerchange emitted
421// WHILE PLAYING must each reach subscribers with their own meta type — timeupdate
422// coalescing must never swallow them.
423const clock4 = fakeClock();
424const c4 = C.create({ data: castText, idleTimeLimit: 2, clock: clock4 });
425const types = [];
426c4.subscribe(function (s, meta) { types.push(meta.type); });
427c4.play();
428clock4.flush(0);
429clock4.flush(200);
430assert.ok(types.includes('timeupdate'), 'timeupdate flows while playing');
431assert.strictEqual(c4.getState().status, 'playing');
432c4.seek(1.5, { origin: 'api' });
433assert.ok(types.includes('seek'), 'seek delivered while playing, saw: ' + types.join(','));
434c4.append('[5,"o","tail"]\n');
435assert.ok(types.includes('durationchange'), 'durationchange delivered while playing');
436c4.setSpeed(2);
437assert.ok(types.includes('speedchange'), 'speedchange delivered while playing');
438c4.setMarkers([[0.5, 'half']]);
439assert.ok(types.includes('markerchange'), 'markerchange delivered');
440assert.strictEqual(c4.getState().markers.filter(m => m.source === 'integration').length, 1);
441c4.dispose();
442
443// ---- marker jumps seek without autoplay ----------------------------------------------
444const clockM = fakeClock();
445const cM = C.create({
446  data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n[1.0,"o","b"]\n[2.0,"o","c"]\n',
447  markers: [[0, 'start'], [1, 'mid'], [2, 'end']],
448  clock: clockM,
449});
450cM.play();
451clockM.flush(0);
452cM.pause();
453assert.strictEqual(cM.getState().status, 'paused');
454cM.seek(0);
455cM.jumpMarker(1, 'keyboard');
456assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9, ' ] seeks to next marker');
457assert.strictEqual(cM.getState().status, 'paused', '[ ] must not autoplay');
458cM.play();
459assert.strictEqual(cM.getState().status, 'playing');
460cM.jumpMarker(1, 'keyboard');
461assert.ok(Math.abs(cM.getCurrentTime() - 2) < 1e-9);
462assert.strictEqual(cM.getState().status, 'playing', 'jump while playing stays playing');
463cM.pause();
464cM.jumpMarker(-1, 'keyboard');
465assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9);
466assert.strictEqual(cM.getState().status, 'paused', '[ while paused stays paused');
467cM.dispose();
468
469// ---- fit:'both' scale rule: content-sized mounts must not ratchet -----------------
470// Mirrors layout()'s definite-height gate (vertical fit only when the mount's height
471// does not track the screen box). The old `availH - 4` trigger shrank forever on
472// content-sized embeds.
473function fitScale(naturalH, availH, definite) {
474  let scale = 1;
475  if (definite && availH > 40 && naturalH * scale > availH) {
476    scale = Math.min(scale, availH / naturalH);
477  }
478  return scale;
479}
480let contentH = 400;
481const natural = 400, bar = 32;
482for (let i = 0; i < 30; i++) {
483  // Content-sized: mount height tracks the box — vertical fit must stay off.
484  const s = fitScale(natural, contentH - bar, false);
485  assert.strictEqual(s, 1, 'content-sized mount must not vertically scale');
486  contentH = natural * s + bar;
487}
488const short = fitScale(400, 200 - 32, true);
489assert.ok(short < 1 && short > 0, 'definite short mount scales down once');
490assert.strictEqual(fitScale(400, 200 - 32, true), short, 'definite scale is stable');
491// The banned ratchet: subtracting 4 from availH forces another shrink every pass.
492let ratchetH = 400;
493for (let i = 0; i < 5; i++) {
494  const avail = ratchetH - bar;
495  if (natural > avail - 4) ratchetH = natural * ((avail - 4) / natural) + bar;
496}
497assert.ok(ratchetH < 390, 'sanity: the old availH-4 rule really does ratchet');
498
499console.log('vt selftest OK');
500"#;
501    let spawned = std::process::Command::new("node")
502      .arg("-")
503      .arg(&bundle)
504      .stdin(std::process::Stdio::piped())
505      .stdout(std::process::Stdio::piped())
506      .stderr(std::process::Stdio::piped())
507      .spawn();
508    let Ok(mut child) = spawned else {
509      let _ = std::fs::remove_dir_all(&dir);
510      return; // no node on this machine — the structural tests above still ran
511    };
512    use std::io::Write;
513    child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
514    let out = child.wait_with_output().unwrap();
515    let _ = std::fs::remove_dir_all(&dir);
516    assert!(
517      out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
518      "vt selftest failed:\nstdout: {}\nstderr: {}",
519      String::from_utf8_lossy(&out.stdout),
520      String::from_utf8_lossy(&out.stderr)
521    );
522  }
523}