use std::collections::BTreeSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::json;
use crate::render::{cast, html, raster, svg, video, Look};
use crate::screen::{Snapshot, Theme};
use crate::session::{Session, Timeline, FRAME_RATE, MAX_VIDEO};
use crate::tape::{Format, Output, Pattern, Shell, Step, Tape, TapeError};
const PROMPT: &str = "❯";
const EXEC_TIMEOUT: Duration = Duration::from_secs(60);
const EXEC_STDERR: u64 = 64 * 1024;
pub fn stem_allowed(stem: &str) -> bool {
!stem.is_empty()
&& stem != "."
&& stem != ".."
&& !stem
.chars()
.any(|c| c == '/' || c == '\\' || c.is_control())
}
fn stem_error(stem: &str) -> String {
format!(
"{stem:?} cannot name a recording: it must not be empty, . or .., \
or contain / or \\"
)
}
#[derive(Clone, Debug, Default)]
pub struct Options {
pub bin_dir: Option<PathBuf>,
pub repo: Option<PathBuf>,
pub theme: Theme,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Presentation {
pub window: bool,
pub caption: Option<String>,
pub key_overlay: bool,
}
impl Default for Presentation {
fn default() -> Self {
Presentation {
window: true,
caption: None,
key_overlay: true,
}
}
}
#[derive(Clone, Debug)]
pub struct Recording {
pub title: String,
pub presentation: Presentation,
pub outputs: Vec<Output>,
pub columns: u16,
pub rows: u16,
pub shots: Vec<(String, Snapshot)>,
pub masks: Vec<(fancy_regex::Regex, String)>,
pub timeline: Timeline,
}
struct Workspace(PathBuf);
impl Workspace {
fn new(stem: &str) -> std::io::Result<Workspace> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let path =
std::env::temp_dir().join(format!("rich-record-{stem}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(path.join(".home"))?;
std::fs::write(
path.join(".home/.inputrc"),
"set enable-bracketed-paste off\n",
)?;
std::fs::write(
path.join(".home/.zshrc"),
format!(
"PROMPT=$'%{{\\e[1;35m%}}{PROMPT}%{{\\e[0m%}} '\nRPROMPT=''\n\
unsetopt PROMPT_SP BEEP\nunset zle_bracketed_paste\nHISTFILE=/dev/null\n"
),
)?;
Ok(Workspace(path))
}
}
impl Drop for Workspace {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn environment(workspace: &Path, tape: &Tape, options: &Options) -> Vec<(String, String)> {
let home = workspace.join(".home");
let inherited = std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into());
let mut dirs: Vec<PathBuf> = options
.bin_dir
.iter()
.map(|dir| std::path::absolute(dir).unwrap_or_else(|_| dir.clone()))
.collect();
dirs.extend(std::env::split_paths(&inherited));
let path = std::env::join_paths(dirs).map_or_else(
|_| inherited.to_string_lossy().into_owned(),
|p| p.to_string_lossy().into_owned(),
);
let mut env: Vec<(String, String)> = vec![
("PATH".into(), path),
("HOME".into(), home.display().to_string()),
(
"XDG_CONFIG_HOME".into(),
home.join(".config").display().to_string(),
),
(
"INPUTRC".into(),
home.join(".inputrc").display().to_string(),
),
("TERM".into(), "xterm-256color".into()),
("COLORTERM".into(), "truecolor".into()),
("LANG".into(), "C.UTF-8".into()),
("LC_ALL".into(), "C.UTF-8".into()),
("TZ".into(), "UTC".into()),
("PROMPT_COMMAND".into(), String::new()),
("HISTFILE".into(), "/dev/null".into()),
];
match tape.shell {
Shell::Bash => env.push((
"PS1".into(),
format!("\\[\\e[1;35m\\]{PROMPT}\\[\\e[0m\\] "),
)),
Shell::Sh => env.push(("PS1".into(), format!("\x1b[1;35m{PROMPT}\x1b[0m "))),
Shell::Zsh => env.push(("ZDOTDIR".into(), home.display().to_string())),
Shell::Fish => {}
}
if let Some(repo) = &options.repo {
let repo = std::path::absolute(repo).unwrap_or_else(|_| repo.clone());
env.push(("REPO".into(), repo.display().to_string()));
}
env.extend(tape.env.iter().cloned());
env
}
fn shell_command(shell: Shell) -> Vec<String> {
let args: &[&str] = match shell {
Shell::Bash => &["--noprofile", "--norc", "-i"],
Shell::Zsh => &["-d", "-i"],
Shell::Fish => &[
"--no-config",
"--private",
"-i",
"-C",
"function fish_prompt; set_color -o magenta; echo -n '❯'; \
set_color normal; echo -n ' '; end; set -g fish_greeting ''; \
set -g fish_autosuggestion_enabled 0",
],
Shell::Sh => &["-i"],
};
std::iter::once(shell.name())
.chain(args.iter().copied())
.map(str::to_string)
.collect()
}
fn bash_major() -> Option<u32> {
let output = Command::new("bash")
.args(["-c", "echo ${BASH_VERSINFO[0]}"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
pub fn warnings(tape: &Tape) -> Vec<String> {
let mut out = Vec::new();
if tape.shell == Shell::Bash {
match bash_major() {
Some(major) if major < 4 => out.push(format!(
"bash {major} is older than 4 (macOS ships 3.2): line editing may \
differ from recordings made with a newer bash; install one (for \
example `brew install bash`) and put it first on PATH"
)),
Some(_) => {}
None => out.push("bash was not found on PATH".into()),
}
}
out
}
fn failed(session: &Session, line: usize) -> Result<(), TapeError> {
match session.error() {
Some(error) => Err(TapeError::new(
line,
format!("the terminal emulator failed: {error}"),
)),
None => Ok(()),
}
}
fn wait_for(
session: &Session,
pattern: &Pattern,
limit: Duration,
line: usize,
) -> Result<(), TapeError> {
let end = Instant::now() + limit;
while Instant::now() < end {
if session.seen(|screen| pattern.is_match(screen)) {
return Ok(());
}
failed(session, line)?;
if !session.alive() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
failed(session, line)?;
Err(TapeError::new(
line,
format!(
"timed out waiting for {pattern}; the screen shows:\n{}",
session.contents()
),
))
}
fn exec(
command: &str,
workspace: &Path,
env: &[(String, String)],
limit: Duration,
line: usize,
) -> Result<(), TapeError> {
let mut child = Command::new("sh")
.args(["-c", command])
.current_dir(workspace)
.env_clear()
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| TapeError::new(line, format!("Exec could not start: {e}")))?;
let (sender, stderr) = std::sync::mpsc::channel();
if let Some(pipe) = child.stderr.take() {
std::thread::spawn(move || {
let mut pipe = pipe;
let mut kept = Vec::new();
let _ = (&mut pipe).take(EXEC_STDERR).read_to_end(&mut kept);
let _ = std::io::copy(&mut pipe, &mut std::io::sink());
let _ = sender.send(kept);
});
}
let stderr = || {
stderr
.recv_timeout(Duration::from_millis(500))
.map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
.unwrap_or_default()
};
let end = Instant::now() + limit;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => {
return Err(TapeError::new(
line,
format!("Exec failed ({status}): {}", stderr()),
));
}
Ok(None) if Instant::now() < end => std::thread::sleep(Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(TapeError::new(
line,
format!("Exec timed out after {}s", limit.as_secs_f64()),
));
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(TapeError::new(line, format!("Exec failed: {error}")));
}
}
}
}
pub fn record(tape: &Tape, stem: &str, options: &Options) -> Result<Recording, TapeError> {
if !stem_allowed(stem) {
return Err(TapeError::new(0, stem_error(stem)));
}
let io = |e: std::io::Error| TapeError::new(0, e.to_string());
let workspace = Workspace::new(stem).map_err(io)?;
let env = environment(&workspace.0, tape, options);
let shell = shell_command(tape.shell);
let mut session = Session::start(
&shell,
&workspace.0,
tape.columns,
tape.rows,
&env,
options.theme.clone(),
)
.map_err(|e| TapeError::new(0, format!("cannot start {}: {e}", tape.shell.name())))?;
let mut typing = Duration::from_millis(40);
let mut timeout = Duration::from_secs(15);
let prompt = Pattern::Text(PROMPT.into());
let mut shots = Vec::new();
wait_for(&session, &prompt, timeout, 0)?;
session.send("clear\r", None).map_err(io)?;
std::thread::sleep(Duration::from_millis(200));
wait_for(&session, &prompt, timeout, 0)?;
session.show();
for (line, step) in &tape.steps {
let io = |e: std::io::Error| TapeError::new(*line, e.to_string());
if !matches!(step, Step::Wait { .. }) {
session.mark();
}
match step {
Step::TypingDelay(delay) => typing = *delay,
Step::Timeout(limit) => timeout = *limit,
Step::Type(text) => {
for c in text.chars() {
session.send(&c.to_string(), None).map_err(io)?;
std::thread::sleep(typing);
}
}
Step::Key { key, count } => {
for _ in 0..*count {
session.send(&key.bytes(), Some(key.label())).map_err(io)?;
std::thread::sleep(typing.max(Duration::from_millis(120)));
}
}
Step::Sleep(delay) => std::thread::sleep(*delay),
Step::Wait {
pattern,
timeout: limit,
} => wait_for(&session, pattern, limit.unwrap_or(timeout), *line)?,
Step::Screenshot(name) => {
std::thread::sleep(Duration::from_millis(150));
shots.push((name.clone(), session.snapshot()));
}
Step::Hide => session.hide(),
Step::Show => session.show(),
Step::Resize { columns, rows } => session.resize(*columns, *rows).map_err(io)?,
Step::Write { path, content } => {
if !crate::tape::write_path_allowed(path) {
return Err(TapeError::new(
*line,
format!("Write path {path:?} must be relative and stay in the workspace"),
));
}
let target = workspace.0.join(path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(io)?;
}
std::fs::write(target, content).map_err(io)?;
}
Step::Exec(command) => exec(command, &workspace.0, &env, EXEC_TIMEOUT, *line)?,
}
failed(&session, *line)?;
}
std::thread::sleep(Duration::from_millis(300));
failed(&session, 0)?;
let timeline = session.finish();
if shots.is_empty() {
return Err(TapeError::new(0, "the tape takes no Screenshot"));
}
Ok(Recording {
title: tape.title.clone().unwrap_or_else(|| stem.to_string()),
presentation: Presentation {
window: tape.window_frame.unwrap_or(true),
caption: tape.caption.clone(),
key_overlay: tape.key_overlay.unwrap_or(true),
},
outputs: tape.outputs.clone(),
columns: tape.columns,
rows: tape.rows,
shots,
masks: tape.masks.clone(),
timeline,
})
}
impl Recording {
pub fn text_grid(&self, snapshot: &Snapshot) -> String {
crate::tape::apply_masks(&self.masks, &snapshot.text_grid())
}
pub fn formats(&self, requested: Formats) -> Formats {
if self.outputs.is_empty() {
return requested;
}
let mut tape = Formats::NONE;
for output in &self.outputs {
tape.set(output.format, true);
}
tape.intersect(requested)
}
pub fn output_path(&self, format: Format, stem: &str) -> String {
self.outputs
.iter()
.rev()
.filter(|output| output.format == format)
.find_map(|output| output.path.clone())
.unwrap_or_else(|| format!("{stem}.{}", format.extension()))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Formats {
pub png: bool,
pub svg: bool,
pub cast: bool,
pub gif: bool,
pub mp4: bool,
pub html: bool,
}
impl Formats {
pub const ALL: Formats = Formats {
png: true,
svg: true,
cast: true,
gif: true,
mp4: true,
html: true,
};
pub const NO_VIDEO: Formats = Formats {
gif: false,
mp4: false,
..Formats::ALL
};
pub const NONE: Formats = Formats {
png: false,
svg: false,
cast: false,
gif: false,
mp4: false,
html: false,
};
pub fn contains(self, format: Format) -> bool {
match format {
Format::Png => self.png,
Format::Svg => self.svg,
Format::Cast => self.cast,
Format::Gif => self.gif,
Format::Mp4 => self.mp4,
Format::Html => self.html,
}
}
pub fn set(&mut self, format: Format, on: bool) {
match format {
Format::Png => self.png = on,
Format::Svg => self.svg = on,
Format::Cast => self.cast = on,
Format::Gif => self.gif = on,
Format::Mp4 => self.mp4 = on,
Format::Html => self.html = on,
}
}
pub fn intersect(self, other: Formats) -> Formats {
let mut out = Formats::NONE;
for format in Format::ALL {
out.set(format, self.contains(format) && other.contains(format));
}
out
}
}
fn manifest(dir: &Path) -> BTreeSet<String> {
let Ok(text) = std::fs::read_to_string(dir.join("provenance.json")) else {
return BTreeSet::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return BTreeSet::new();
};
json.get("screenshots")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.filter(|name| crate::tape::screenshot_name_allowed(name))
.map(str::to_string)
.collect()
}
pub fn orphans(recording: &Recording, dir: &Path) -> Vec<String> {
let taken: BTreeSet<&str> = recording
.shots
.iter()
.map(|(name, _)| name.as_str())
.collect();
manifest(dir)
.into_iter()
.filter(|name| !taken.contains(name.as_str()))
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Problem {
Differs { name: String, diff: String },
Orphaned { name: String },
}
impl std::fmt::Display for Problem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Problem::Differs { name, diff } => write!(f, "screenshot {name} differs\n{diff}"),
Problem::Orphaned { name } => {
write!(
f,
"committed screenshot {name} is no longer taken; regenerate to remove it"
)
}
}
}
}
pub fn check(recording: &Recording, dir: &Path) -> Vec<Problem> {
let mut problems = Vec::new();
for (name, snapshot) in &recording.shots {
let got = recording.text_grid(snapshot);
let want = std::fs::read_to_string(dir.join(format!("{name}.txt"))).unwrap_or_default();
if got != want {
let diff = rich_ext::diff::TextDiff::new(&want, &got).unified("committed", "this run");
problems.push(Problem::Differs {
name: name.clone(),
diff,
});
}
}
problems.extend(
orphans(recording, dir)
.into_iter()
.map(|name| Problem::Orphaned { name }),
);
problems
}
fn fingerprint(bytes: &[u8]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
format!("{hash:016x}")
}
fn invalid(message: String) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
}
fn check_pixels(
columns: usize,
rows: usize,
fonts: &raster::Fonts,
options: &raster::Frame<'_>,
) -> std::io::Result<()> {
let (width, height) = raster::size(columns, rows, fonts, options);
if width.saturating_mul(height) > raster::MAX_PIXELS {
return Err(invalid(format!(
"an image of {columns}x{rows} cells would be {width}x{height} pixels, more than \
{} million; use a smaller Size or font",
raster::MAX_PIXELS / 1_000_000
)));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Written {
pub formats: Formats,
pub paths: Vec<PathBuf>,
pub skipped: Vec<Skipped>,
}
impl Written {
pub fn is_complete(&self) -> bool {
self.skipped.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Skipped {
pub format: Format,
pub path: PathBuf,
pub reason: String,
}
pub fn write_selected(
recording: &Recording,
dir: &Path,
stem: &str,
allowed: Formats,
fonts: &raster::Fonts,
theme: &Theme,
provenance: Option<(&Path, &[u8])>,
) -> std::io::Result<Written> {
let formats = recording.formats(allowed);
let encode = Formats {
mp4: formats.mp4 && video::ffmpeg_available(),
..formats
};
let paths = write(recording, dir, stem, encode, fonts, theme, provenance)?;
let mut skipped = Vec::new();
if formats.mp4 {
let path = dir.join(recording.output_path(Format::Mp4, stem));
if !paths.contains(&path) {
skipped.push(Skipped {
format: Format::Mp4,
path,
reason: "ffmpeg not found".into(),
});
}
}
Ok(Written {
formats,
paths,
skipped,
})
}
pub fn write(
recording: &Recording,
dir: &Path,
stem: &str,
formats: Formats,
fonts: &raster::Fonts,
theme: &Theme,
provenance: Option<(&Path, &[u8])>,
) -> std::io::Result<Vec<PathBuf>> {
if !stem_allowed(stem) {
return Err(invalid(stem_error(stem)));
}
let video = formats.gif || formats.mp4 || formats.html;
if video && recording.timeline.truncated {
return Err(invalid(format!(
"the recording is longer than {}s, too long for video; record it without \
GIF, MP4 or the HTML player (--no-video), or shorten the tape",
MAX_VIDEO.as_secs()
)));
}
let presentation = &recording.presentation;
let look = Look {
title: &recording.title,
window: presentation.window,
caption: presentation.caption.as_deref(),
};
let still = raster::Frame {
title: &recording.title,
key: None,
size: 28.0,
window: look.window,
caption: look.caption,
};
if formats.png {
for (_, snapshot) in &recording.shots {
check_pixels(snapshot.columns(), snapshot.rows.len(), fonts, &still)?;
}
}
let samples = if video {
video::sample_with(&recording.timeline, FRAME_RATE, presentation.key_overlay)
} else {
Vec::new()
};
let (video_width, video_height) = video::size(&recording.timeline, &samples, fonts, &look);
if video_width.saturating_mul(video_height) > raster::MAX_PIXELS {
return Err(invalid(format!(
"video frames would be {video_width}x{video_height} pixels, more than {} million; \
use a smaller Size or font",
raster::MAX_PIXELS / 1_000_000
)));
}
std::fs::create_dir_all(dir)?;
for name in orphans(recording, dir) {
for suffix in ["txt", "png", "svg"] {
let _ = std::fs::remove_file(dir.join(format!("{name}.{suffix}")));
}
}
let mut written = Vec::new();
let save = |written: &mut Vec<PathBuf>, name: String, bytes: &[u8]| -> std::io::Result<()> {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, bytes)?;
written.push(path);
Ok(())
};
for (name, snapshot) in &recording.shots {
save(
&mut written,
format!("{name}.txt"),
recording.text_grid(snapshot).as_bytes(),
)?;
if formats.svg {
save(
&mut written,
format!("{name}.svg"),
svg::svg(snapshot, theme, &look).as_bytes(),
)?;
}
if formats.png {
save(
&mut written,
format!("{name}.png"),
&raster::render(snapshot, theme, fonts, &still).png(),
)?;
}
}
if formats.cast {
let cast = cast::cast(
&recording.timeline,
recording.columns,
recording.rows,
&recording.title,
theme,
);
save(
&mut written,
recording.output_path(Format::Cast, stem),
cast.as_bytes(),
)?;
}
if formats.html {
let page = html::page(
&look,
&recording.shots,
&recording.timeline,
theme,
presentation.key_overlay,
);
save(
&mut written,
recording.output_path(Format::Html, stem),
page.as_bytes(),
)?;
}
let mp4_path = dir.join(recording.output_path(Format::Mp4, stem));
if formats.mp4 {
if let Some(parent) = mp4_path.parent() {
std::fs::create_dir_all(parent)?;
}
}
let mut mp4 = if formats.mp4 && video::ffmpeg_available() {
Some(video::Mp4::start(&mp4_path, video_width, video_height)?)
} else {
None
};
let timeline = &recording.timeline;
if formats.gif {
let gif_path = dir.join(recording.output_path(Format::Gif, stem));
if let Some(parent) = gif_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::io::BufWriter::new(std::fs::File::create(&gif_path)?);
let mut pass = 0;
raster::gif_streamed(
video_width,
video_height,
|sink| {
pass += 1;
video::render_each(timeline, &samples, theme, fonts, &look, &mut |canvas, s| {
if pass == 1 {
if let Some(mp4) = mp4.as_mut() {
mp4.write(&canvas, s)?;
}
}
sink(canvas, s)
})
},
&mut file,
)?;
std::io::Write::flush(&mut file)?;
written.push(gif_path);
} else if let Some(mp4) = mp4.as_mut() {
video::render_each(timeline, &samples, theme, fonts, &look, &mut |canvas, s| {
mp4.write(&canvas, s)
})?;
}
let mp4_written = mp4.is_some();
if let Some(mp4) = mp4 {
mp4.finish()?;
}
if let Some((tape_path, tape_bytes)) = provenance {
let output = |program: &str, args: &[&str]| {
Command::new(program)
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
};
let screenshots: Vec<&str> = recording
.shots
.iter()
.map(|(name, _)| name.as_str())
.collect();
let json = json!({
"tape": tape_path.display().to_string(),
"tape_fnv1a64": fingerprint(tape_bytes),
"recorder": format!("rs-rich-record {}", env!("CARGO_PKG_VERSION")),
"rich": output("rich", &["--version"]),
"commit": output("git", &["rev-parse", "HEAD"]),
"screenshots": screenshots,
"note": "Every frame is real output of the program under test on a PTY.",
});
save(
&mut written,
"provenance.json".into(),
format!("{:#}\n", json).as_bytes(),
)?;
}
if mp4_written {
written.push(mp4_path);
}
Ok(written)
}
#[cfg(test)]
mod tests {
use super::*;
fn recording() -> Recording {
let mut parser = vt100::Parser::new(3, 10, 0);
parser.process(b"hi");
let snapshot = Snapshot::from_screen(parser.screen(), &Theme::default());
Recording {
title: "t".into(),
presentation: Presentation::default(),
outputs: Vec::new(),
columns: 10,
rows: 3,
shots: vec![("shot".into(), snapshot.clone())],
masks: Vec::new(),
timeline: Timeline {
frames: vec![(0.0, snapshot)],
..Timeline::default()
},
}
}
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("rich-record-unit-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
const TEXT_ONLY: Formats = Formats::NONE;
#[test]
fn stems_cannot_leave_the_output_directory() {
for stem in ["", ".", "..", "a/b", "a\\b", "../x", "x\u{0}"] {
assert!(!stem_allowed(stem), "{stem:?}");
}
for stem in ["demo", "my demo", ".hidden", "a..b", "v1.2"] {
assert!(stem_allowed(stem), "{stem:?}");
}
let dir = scratch("stem");
let fonts = raster::Fonts::embedded();
let error = write(
&recording(),
&dir,
"..",
TEXT_ONLY,
&fonts,
&Theme::default(),
None,
)
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(!dir.exists());
let tape = crate::tape::parse("Screenshot x").unwrap();
let error = record(&tape, "a/b", &Options::default()).unwrap_err();
assert!(error.message.contains("cannot name a recording"), "{error}");
}
#[test]
fn a_recording_too_long_for_video_says_so() {
let mut recording = recording();
recording.timeline.truncated = true;
let dir = scratch("long");
let fonts = raster::Fonts::embedded();
let gif = Formats {
gif: true,
..TEXT_ONLY
};
let error = write(
&recording,
&dir,
"long",
gif,
&fonts,
&Theme::default(),
None,
)
.unwrap_err();
assert!(error.to_string().contains("too long for video"), "{error}");
assert!(!dir.exists());
let html = Formats {
html: true,
..TEXT_ONLY
};
let error = write(
&recording,
&dir,
"long",
html,
&fonts,
&Theme::default(),
None,
)
.unwrap_err();
assert!(error.to_string().contains("too long for video"), "{error}");
assert!(!dir.exists());
write(
&recording,
&dir,
"long",
TEXT_ONLY,
&fonts,
&Theme::default(),
None,
)
.unwrap();
assert!(dir.join("shot.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gif_is_written_by_streaming() {
let dir = scratch("gif");
let fonts = raster::Fonts::embedded();
let gif = Formats {
gif: true,
..TEXT_ONLY
};
let written = write(
&recording(),
&dir,
"g",
gif,
&fonts,
&Theme::default(),
None,
)
.unwrap();
assert!(written.iter().any(|p| p.ends_with("g.gif")), "{written:?}");
let bytes = std::fs::read(dir.join("g.gif")).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn outputs_choose_formats_and_files() {
let mut recording = recording();
assert_eq!(recording.formats(Formats::ALL), Formats::ALL);
recording.outputs = crate::tape::parse("Output gif html\nOutput pages/t.html\n")
.unwrap()
.outputs;
let chosen = recording.formats(Formats::ALL);
assert!(chosen.gif && chosen.html && !chosen.png && !chosen.cast);
let chosen = recording.formats(Formats::NO_VIDEO);
assert!(!chosen.gif && chosen.html);
assert_eq!(recording.output_path(Format::Html, "s"), "pages/t.html");
assert_eq!(recording.output_path(Format::Gif, "s"), "s.gif");
let dir = scratch("outputs");
let fonts = raster::Fonts::embedded();
let html = Formats {
html: true,
..TEXT_ONLY
};
let written = write(
&recording,
&dir,
"s",
recording.formats(html),
&fonts,
&Theme::default(),
None,
)
.unwrap();
assert!(dir.join("pages/t.html").exists(), "{written:?}");
assert!(dir.join("shot.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_ffmpeg_a_long_recording_reports_the_video_skipped() {
let mut recording = recording();
recording.timeline.truncated = true;
recording.outputs = crate::tape::parse("Output mp4\n").unwrap().outputs;
let dir = scratch("selected-long");
let result = write_selected(
&recording,
&dir,
"s",
Formats::ALL,
&raster::Fonts::embedded(),
&Theme::default(),
None,
);
if video::ffmpeg_available() {
let error = result.unwrap_err();
assert!(error.to_string().contains("too long for video"), "{error}");
} else {
let written = result.unwrap();
assert!(written.formats.mp4, "{written:?}");
assert_eq!(written.skipped.len(), 1, "{written:?}");
assert_eq!(written.skipped[0].reason, "ffmpeg not found");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_selected_honours_the_tape_and_reports_a_skipped_video() {
let mut recording = recording();
recording.outputs = crate::tape::parse("Output png\n").unwrap().outputs;
let dir = scratch("selected");
let fonts = raster::Fonts::embedded();
let written = write_selected(
&recording,
&dir,
"s",
Formats::ALL,
&fonts,
&Theme::default(),
None,
)
.unwrap();
assert!(written.formats.png && !written.formats.svg && !written.formats.gif);
assert!(dir.join("shot.png").exists() && !dir.join("shot.svg").exists());
assert!(!dir.join("s.cast").exists());
assert!(written.is_complete(), "{written:?}");
let _ = std::fs::remove_dir_all(&dir);
recording.outputs = crate::tape::parse("Output mp4\n").unwrap().outputs;
let dir = scratch("selected-mp4");
let written = write_selected(
&recording,
&dir,
"s",
Formats::ALL,
&fonts,
&Theme::default(),
None,
)
.unwrap();
let mp4 = dir.join("s.mp4");
if video::ffmpeg_available() {
assert!(
written.is_complete() && written.paths.contains(&mp4),
"{written:?}"
);
} else {
assert_eq!(
written.skipped,
[Skipped {
format: Format::Mp4,
path: mp4.clone(),
reason: "ffmpeg not found".into(),
}]
);
assert!(!written.is_complete() && !mp4.exists());
}
let written = write_selected(
&recording,
&dir,
"s",
Formats::NO_VIDEO,
&fonts,
&Theme::default(),
None,
)
.unwrap();
assert!(!written.formats.mp4 && written.is_complete());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn presentation_reaches_the_stills() {
let mut recording = recording();
let dir = scratch("look");
let fonts = raster::Fonts::embedded();
let stills = Formats {
png: true,
svg: true,
..TEXT_ONLY
};
write(
&recording,
&dir,
"l",
stills,
&fonts,
&Theme::default(),
None,
)
.unwrap();
let framed = std::fs::read(dir.join("shot.png")).unwrap();
let svg = std::fs::read_to_string(dir.join("shot.svg")).unwrap();
assert!(svg.contains("#ff5f57"));
recording.presentation = Presentation {
window: false,
caption: Some("A caption".into()),
key_overlay: false,
};
write(
&recording,
&dir,
"l",
stills,
&fonts,
&Theme::default(),
None,
)
.unwrap();
let bare = std::fs::read(dir.join("shot.png")).unwrap();
let svg = std::fs::read_to_string(dir.join("shot.svg")).unwrap();
assert!(!svg.contains("#ff5f57") && svg.contains("A caption"));
assert_ne!(framed, bare);
assert_eq!(
std::fs::read_to_string(dir.join("shot.txt")).unwrap(),
"hi\n\n\n"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn exec_reports_stderr_and_times_out() {
let dir = std::env::temp_dir();
let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())];
let limit = Duration::from_secs(10);
assert!(exec("true", &dir, &env, limit, 3).is_ok());
let error = exec("echo oops >&2; exit 2", &dir, &env, limit, 3).unwrap_err();
assert_eq!(error.line, 3);
assert!(error.message.contains("oops"), "{error}");
let error = exec(
"head -c 1000000 /dev/zero | tr '\\0' x >&2; exit 1",
&dir,
&env,
limit,
4,
)
.unwrap_err();
assert!(
error.message.starts_with("Exec failed"),
"{}",
error.message
);
assert!(error.message.len() < 70 * 1024);
let started = Instant::now();
let error = exec("sleep 30", &dir, &env, Duration::from_millis(300), 5).unwrap_err();
assert!(error.message.contains("timed out"), "{error}");
assert!(started.elapsed() < Duration::from_secs(5));
}
}