Selkie--Test--Snapshot

NAME

Selkie::Test::Snapshot - Golden-file snapshot testing for widget rendering

SYNOPSIS

use Test;
use Selkie::Test::Snapshot;
use Selkie::Widget::Text;
use Selkie::Sizing;
use Selkie::Style;

my $header = Selkie::Widget::Text.new(
    text   => ' Selkie 1.0',
    sizing => Sizing.fixed(1),
    style  => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);

# First run: creates t/snapshots/header.snap from the rendered output.
# Subsequent runs: compares the current render against the saved file.
snapshot-ok $header, 'header', rows => 1, cols => 30;

done-testing;

DESCRIPTION

Classic snapshot-testing pattern (Rails' rspec-snapshot, Jest's toMatchSnapshot, Elixir's Mneme). First run writes the rendered output of a widget to a golden file; subsequent runs render the widget again and fail if the output differs.

Under the hood:

  • Initialises notcurses against stdout in headless mode (no alternate screen, no signal handlers, banners suppressed, stderr redirected to silence notcurses shutdown chatter).

  • Sizes the stdplane to the requested dimensions.

  • Gives the widget that plane via init-plane.

  • Calls the widget's render.

  • Reads the rendered cells back with ncplane_at_yx row by row.

  • Shuts notcurses down cleanly.

Trailing whitespace is trimmed from each row; trailing empty rows are dropped so resizes don't churn snapshots unnecessarily.

By default, snapshots capture character content only, not styles. That's a deliberate tradeoff โ€” catches layout bugs, text mistakes, and most rendering regressions without the complexity of a style-aware snapshot format. Diffs stay one-row-per-row legible in PR review.

Practical consequence: widgets whose state changes are style-only (e.g. ListView cursor, Button focus highlight, Checkbox toggle color) won't produce a different snapshot between states. Test those via the widget's attributes or the Selkie::Test::Keys + event assertions directly, not via plain snapshots.

For widgets where colour is the regression โ€” Heatmap, multi-series LineChart, multi-bar BarChart, themed elements โ€” pass :capture-styles to render-to-string (or snapshot-ok). The output then includes a parallel grid of style keys and a legend mapping each key to (fg, bg, stylemask). The harness routes styled goldens to xt/snapshots/golden-styled/ automatically (it detects the format marker on the first line of stdout).

WORKFLOW

  • First run or missing snapshot: the file is created and the test passes.

  • Matching snapshot: the test passes silently.

  • Differing snapshot: the test fails, and a diff is printed to TAP diagnostics.

To accept new output (when an intentional change makes existing snapshots stale), re-run with the update env var:

SELKIE_UPDATE_SNAPSHOTS=1 prove6 -l t

Every snapshot-ok call overwrites its file in update mode. Then re-run without the flag to confirm everything matches.

EXAMPLES

A basic widget

snapshot-ok $my-widget, 'my-widget-default', rows => 10, cols => 40;

Testing multiple states of the same widget

use Selkie::Test::Keys;

my $list = Selkie::Widget::ListView.new(sizing => Sizing.flex);
$list.set-items(<alpha beta gamma>);

snapshot-ok $list, 'list-initial',  rows => 5, cols => 20;

press-key($list, 'down');
snapshot-ok $list, 'list-cursor-1', rows => 5, cols => 20;

press-key($list, 'end');
snapshot-ok $list, 'list-cursor-end', rows => 5, cols => 20;

Custom snapshot directory

snapshot-ok $widget, 'thing', :rows(4), :cols(20), dir => 'xt/snaps';

Style-aware snapshot

For widgets where colour or text style is the regression you care about, opt into the styled format:

snapshot-ok $heatmap, 'heatmap-viridis', :rows(8), :cols(20), :capture-styles;

The captured output begins with === styled-snapshot v1 === and contains three blocks (--- glyphs ---, --- styles ---, --- legend ---). The harness recognises the marker and stores the golden under {$dir}/golden-styled/{$name}.snap rather than {$dir}/golden/{$name}.snap.

Style equality is the (fg-rgb, bg-rgb, stylemask) tuple. Cells with no fg, no bg, and no styles get the . key. Other tuples get single-character keys (A, B, ..., Z, a, ..., z, 0, ..., 9) in first-seen order.

FILE FORMAT

Snapshots are plain UTF-8 text files. One line per rendered row; trailing whitespace stripped; trailing blank rows removed; final newline appended.

No metadata, no escaped characters beyond what the widget actually rendered. This means snapshot files render legibly on GitHub and are trivial to diff by eye:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Hello, Selkieโ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

CAVEATS

  • Styles aren't captured. Two widgets that render the same glyphs in different colors produce identical snapshots. If style matters, test it via the widget's attributes directly.

  • Non-ASCII width. Snapshots use one-character-per-cell. Wide characters (CJK, emoji) may render over multiple cells; the captured output reflects what ncplane_at_yx returns at each cell position.

  • Real notcurses init. Each call spins notcurses up and tears it down. ~50-100ms per snapshot. For small test suites this is fine; for large ones, group related snapshots in the same test and share context if performance matters.

  • Headless-friendly. Init is done against a pipe-compatible output, so tests run in CI without a TTY. Output to stderr is silenced during init/stop to avoid the notcurses "signals weren't registered" diagnostic leaking into TAP.

SEE ALSO

sub fopen

sub fopen(
    Str $,
    Str $
) returns NativeCall::Types::Pointer

Marker line that identifies a styled snapshot. The fork-per-scenario harness (Selkie::Test::Snapshot::Harness) detects this on the first line of subprocess stdout and routes the golden file to a separate golden-styled/ subdirectory. Plain snapshots without this marker continue to use the existing golden/ subdirectory.

sub render-to-string

sub render-to-string(
    Selkie::Widget $widget,
    Int :$rows where { ... } = 24,
    Int :$cols where { ... } = 80,
    Bool :$capture-styles = Bool::False
) returns Str

Render a widget to a plain-text string via a shared headless notcurses instance. Returns the rendered cells row-by-row, joined with newlines. Trailing whitespace on each line is stripped, and trailing blank lines are removed. The widget is given its own plane as a child of the stdplane, sized to $rows ร— $cols. Containers that manage child planes in their render work correctly โ€” the standard mount path is exercised. The notcurses instance persists across calls within a test process (init is once-per-process). The widget's plane is destroyed after each call so renders don't leak. Pass :capture-styles to emit the style-aware format instead of the plain glyph grid โ€” see Selkie::Test::Snapshot Pod6 for the format spec. The harness routes styled goldens to golden-styled/ automatically.

sub snapshot-ok

sub snapshot-ok(
    Selkie::Widget $widget,
    Str:D $name,
    Int :$rows where { ... } = 24,
    Int :$cols where { ... } = 80,
    Bool :$capture-styles = Bool::False,
    Str :$dir is copy
) returns Mu

Test assertion: render the widget to $rows ร— $cols and compare against a stored snapshot file. =item First run or missing file: the snapshot is created and the test passes. =item Matching output: the test passes. =item Differing output: the test fails and a unified-ish diff is printed as TAP diagnostics. Set the env var SELKIE_UPDATE_SNAPSHOTS to a truthy value to overwrite existing snapshots with current output. The snapshot directory defaults to t/snapshots. With :capture-styles the directory is automatically suffixed with -styled (default: t/snapshots-styled) so plain and styled goldens never collide. Override $dir for a custom location; when overriding alongside :capture-styles, suffix $dir yourself. The directory is auto-created on first use.

Selkie v0.10.0

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.4.0+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::App
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalSequences
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.