Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ Timeout defaults:

| Command | Description |
| --- | --- |
| `screenshot [PATH] [-o PATH] [--full] [--zoom N] [--background COLOR \| --transparent]` | Print text or save SVG. |
| `screenshot [PATH] [-o PATH] [--full] [--zoom N] [--background COLOR \| --transparent]` | Print text or save SVG or PNG. |
| `record start PATH [options] [--background COLOR \| --transparent]` | Start APNG, GIF, MP4, or asciinema recording. |
| `record stop` | Finish the recording. |
| `get-recording [SESSION] [--config PATH]` | Print the automatic asciinema recording. |
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Do not use a fixed sleep when a wait can describe the state.

## Capture output

Use `screenshot` for text or SVG. SVG, APNG, and GIF support `--background COLOR` and `--transparent`. MP4 supports `--background` only. Use `record` for APNG, GIF, MP4, or asciinema. Use recording mode `on-failure` for test artifacts.
Use `screenshot` for text, SVG, or PNG. SVG, PNG, APNG, and GIF support `--background COLOR` and `--transparent`. MP4 supports `--background` only. Use `record` for APNG, GIF, MP4, or asciinema. Use recording mode `on-failure` for test artifacts.

## References

Expand Down
2 changes: 1 addition & 1 deletion bindings/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Snapshot options are `update`, `includeStyle`, and `includeTitle`.

| Method | Description |
| --- | --- |
| `screenshot(path?, { full?, zoom?, background?, transparent? })` | Return text or save SVG. |
| `screenshot(path?, { full?, zoom?, background?, transparent? })` | Return text or save SVG or PNG. |
| `startRecording(path, options?)` | Start APNG, GIF, MP4, or asciinema recording. |
| `stopRecording()` | Finish the recording and return its path. |

Expand Down
2 changes: 1 addition & 1 deletion bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Snapshot options are `update`, `include_style`, and `include_title`.

| Method | Description |
| --- | --- |
| `await screenshot(path=None, full=False, zoom=None, background=None, transparent=False)` | Return text or save SVG. |
| `await screenshot(path=None, full=False, zoom=None, background=None, transparent=False)` | Return text or save SVG or PNG. |
| `await start_recording(path, **options)` | Start APNG, GIF, MP4, or asciinema recording. |
| `await stop_recording()` | Finish the recording and return its path. |

Expand Down
3 changes: 3 additions & 0 deletions crates/tui-test-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ tui-test = { workspace = true, features = [

[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = ["Win32_System_Console"] }

[dev-dependencies]
png.workspace = true
12 changes: 6 additions & 6 deletions crates/tui-test-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,21 +151,21 @@ pub struct Cli {

#[derive(Args)]
pub struct ScreenshotArgs {
/// Write an SVG image to this path (alias for --out).
/// Write an SVG or PNG image to this path (alias for --out).
pub path: Option<String>,
/// Write an SVG image to this path.
/// Write an SVG or PNG image to this path.
#[arg(short, long)]
pub out: Option<String>,
/// Include scrollback, not just the visible viewport.
#[arg(long)]
pub full: bool,
/// Scale the SVG dimensions while keeping the same terminal cells.
/// Scale image dimensions while keeping the same terminal cells.
#[arg(long)]
pub zoom: Option<f64>,
/// Canvas background color (#rgb or #rrggbb).
#[arg(long, conflicts_with = "transparent")]
pub background: Option<String>,
/// Leave the SVG canvas transparent.
/// Leave the image canvas transparent.
#[arg(long)]
pub transparent: bool,
}
Expand Down Expand Up @@ -271,8 +271,8 @@ pub enum Command {
#[arg(long)]
full: bool,
},
/// Capture a screenshot: terminal text to stdout, or a full-color SVG image
/// when an output path is given (crisp at any zoom).
/// Capture a screenshot: terminal text to stdout, or a full-color SVG/PNG
/// image selected by the output extension (SVG is the default).
Screenshot(ScreenshotArgs),
/// Start or stop an animated terminal recording.
Record {
Expand Down
2 changes: 1 addition & 1 deletion crates/tui-test-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1327,7 +1327,7 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\
[global options] -- <program> [args...] (direct run shorthand)\n\
restart [--graceful-timeout MS]\n\
sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\
INSPECT state | text [--full] | screenshot [-o file.svg] [--full] [--zoom N]\n\
INSPECT state | text [--full] | screenshot [-o file.svg|file.png] [--full] [--zoom N]\n\
[--background COLOR | --transparent]\n\
find text \"T\" [selector/style options] | cells X Y [W H]\n\
get command|output|exit-code|cwd|cursor|size|title|clipboard|bells|bell-events\n\
Expand Down
61 changes: 61 additions & 0 deletions crates/tui-test-cli/tests/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ const CALL_TIMEOUT: Duration = Duration::from_secs(60);

static SANDBOX_SEQ: AtomicU32 = AtomicU32::new(0);

fn contains_rgba(pixels: &[u8], expected: [u8; 4]) -> bool {
pixels.chunks(4).any(|pixel| pixel == expected)
}

struct Sandbox {
label: &'static str,
home: PathBuf,
Expand Down Expand Up @@ -320,6 +324,63 @@ fn monitor_windows_console_forwards_ctrl_z_and_unicode_without_detaching() {
target.ok(&["daemon", "status"]);
}

#[test]
fn screenshots_dispatch_by_extension_without_changing_svg_output() {
let sandbox = Sandbox::new("screenshot-formats");
let program = r#"printf "\033[41m \033[0m\033[38;2;0;255;0mX\033[0m\033]12;#ff00ff\007\033[1;4H"; sleep 30"#;
sandbox.ok(&[
"run", "--cols", "4", "--rows", "3", "--", "bash", "--norc", "-c", program,
]);
sandbox.wait_for_text("X", "5000");

let svg = sandbox.home.join("screen.svg");
let extensionless = sandbox.home.join("screen");
sandbox.ok(&["screenshot", svg.to_str().unwrap()]);
sandbox.ok(&["screenshot", extensionless.to_str().unwrap()]);
let svg_bytes = std::fs::read(&svg).unwrap();
assert!(svg_bytes.starts_with(b"<svg "));
assert_eq!(svg_bytes, std::fs::read(&extensionless).unwrap());

let zoomed_svg = sandbox.home.join("screen-zoomed.svg");
sandbox.ok(&["screenshot", zoomed_svg.to_str().unwrap(), "--zoom", "2"]);
let zoomed_svg = std::fs::read_to_string(zoomed_svg).unwrap();
assert!(
zoomed_svg.contains(r#"width="236" height="326" viewBox="0 0 118 163""#),
"unexpected zoomed SVG dimensions: {zoomed_svg}"
);

let png = sandbox.home.join("screen.PNG");
sandbox.ok(&["screenshot", png.to_str().unwrap(), "--zoom", "2"]);
let png_bytes = std::fs::read(&png).unwrap();
assert_eq!(&png_bytes[..8], b"\x89PNG\r\n\x1a\n");
let decoder = png::Decoder::new(std::io::Cursor::new(&png_bytes));
let mut reader = decoder.read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut pixels).unwrap();
assert_eq!((info.width, info.height), (236, 326));
let pixels = &pixels[..info.buffer_size()];
assert!(
contains_rgba(pixels, [128, 0, 0, 255]),
"styled red background cell was not rendered"
);
assert!(
contains_rgba(pixels, [0, 255, 0, 255]),
"non-empty green glyph was not rendered"
);
assert!(
contains_rgba(pixels, [255, 0, 255, 255]),
"magenta cursor was not rendered"
);

let unsupported = sandbox.home.join("screen.gif");
let output = sandbox.run(&["screenshot", unsupported.to_str().unwrap()]);
assert_eq!(output.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&output.stderr).contains("unsupported screenshot extension '.gif'")
);
assert!(!unsupported.exists());
}

#[test]
fn sandbox_paths_fit_in_a_unix_socket_address() {
const SUN_PATH_MAX: usize = 103;
Expand Down
102 changes: 91 additions & 11 deletions crates/tui-test/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2655,6 +2655,28 @@ fn svg_snapshot(session: &TerminalSession, full: bool) -> SvgSnapshot {
snapshot
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScreenshotFormat {
Svg,
Png,
}

impl ScreenshotFormat {
fn infer(path: &str) -> Result<Self, TuiTestError> {
let extension = std::path::Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
None | Some("svg") => Ok(Self::Svg),
Some("png") => Ok(Self::Png),
Some(extension) => Err(TuiTestError::usage(format!(
"unsupported screenshot extension '.{extension}'; use .svg or .png"
))),
}
}
}

fn screenshot(
session: &TerminalSession,
full: bool,
Expand All @@ -2665,18 +2687,55 @@ fn screenshot(
match path {
Some(path) => {
let zoom = crate::api::resolve_zoom(zoom)?;
let format = ScreenshotFormat::infer(&path)?;
let snapshot = svg_snapshot(session, full);
let svg = crate::render::svg::render_svg_with_zoom(
&snapshot.rows,
snapshot.cols,
&snapshot.render_state,
snapshot.cursor,
snapshot.title.as_deref(),
zoom,
background,
);
std::fs::write(&path, svg)
.map_err(|error| TuiTestError::internal(error.to_string()))?;
match format {
ScreenshotFormat::Svg => {
let svg = crate::render::svg::render_svg_with_zoom(
&snapshot.rows,
snapshot.cols,
&snapshot.render_state,
snapshot.cursor,
snapshot.title.as_deref(),
zoom,
background,
);
std::fs::write(&path, svg)
.map_err(|error| TuiTestError::internal(error.to_string()))?;
}
ScreenshotFormat::Png => {
#[cfg(feature = "recording-raster")]
{
let rows = snapshot.rows.len();
let frame = crate::record::frames::Frame {
grid: snapshot.rows,
title: snapshot.title,
duration: Duration::ZERO,
render_state: snapshot.render_state,
cursor: snapshot.cursor,
};
let mut renderer = crate::render::raster::GridRenderer::for_screenshot(
snapshot.cols,
rows,
zoom,
background,
)
.map_err(|error| TuiTestError::internal(error.to_string()))?;
crate::render::encode::encode_png(
std::path::Path::new(&path),
&frame,
&mut renderer,
)
.map_err(|error| TuiTestError::internal(error.to_string()))?;
}
#[cfg(not(feature = "recording-raster"))]
{
return Err(TuiTestError::usage(
"PNG screenshots require the tui-test 'recording-raster' feature",
));
}
}
}
Ok(ScreenshotResult::Path(path))
}
None if zoom.is_some() || background.is_some() => Err(TuiTestError::usage(
Expand Down Expand Up @@ -2908,6 +2967,27 @@ mod tests {
assert!(format!("{error:?}").contains("no meaning"), "{error:?}");
}

#[test]
fn screenshot_format_defaults_to_svg_and_rejects_unknown_extensions() {
assert_eq!(
ScreenshotFormat::infer("screen").unwrap(),
ScreenshotFormat::Svg
);
assert_eq!(
ScreenshotFormat::infer("screen.SVG").unwrap(),
ScreenshotFormat::Svg
);
assert_eq!(
ScreenshotFormat::infer("screen.PNG").unwrap(),
ScreenshotFormat::Png
);
let error = ScreenshotFormat::infer("screen.gif").unwrap_err();
assert_eq!(error.kind, ErrorKind::Usage);
assert!(error.message.contains(".gif"));
assert!(error.message.contains(".svg"));
assert!(error.message.contains(".png"));
}

#[test]
fn cell_model_reports_the_whole_vocabulary() {
let cell = EmuCell {
Expand Down
39 changes: 39 additions & 0 deletions crates/tui-test/src/render/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ pub(crate) fn encode(
}
}

pub(crate) fn encode_png(
path: &Path,
frame: &Frame,
renderer: &mut dyn FrameRenderer,
) -> anyhow::Result<()> {
let (width, height) = renderer.pixel_size();
let output = BufWriter::new(File::create(path)?);
let mut encoder = png::Encoder::new(output, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive);
let mut writer = encoder.write_header()?;
let image = renderer.render(frame)?;
writer.write_image_data(image.as_raw())?;
writer.finish()?;
Ok(())
}

fn encode_mp4(
path: &Path,
frames: &[Frame],
Expand Down Expand Up @@ -480,6 +498,27 @@ mod tests {
}
}

#[test]
fn png_is_static_and_round_trips_dimensions_and_color() {
let path = temp_path("png");
let frame = frame(Color::Rgb(200, 10, 20), Duration::ZERO);
let mut renderer = GridRenderer::with_zoom(1, 1, 1.5).unwrap();
encode_png(&path, &frame, &mut renderer).unwrap();

let bytes = std::fs::read(&path).unwrap();
let chunks = png_chunks(&bytes);
assert_eq!(png_dimensions(&chunks), renderer.pixel_size());
assert!(!chunks.iter().any(|(kind, _)| kind == b"acTL"));
let pixel = decode_first_png_pixel(
&path,
((crate::render::raster::CANVAS_PADDING + 20) as f64 * 1.5) as u32,
((crate::render::raster::CANVAS_PADDING + 48) as f64 * 1.5) as u32,
);
assert_eq!(&pixel[..3], &[200, 10, 20]);

std::fs::remove_file(path).unwrap();
}

#[test]
fn repeated_apng_encodes_are_byte_identical() {
let first = temp_path("png");
Expand Down
Loading
Loading