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