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