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_yxrow 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 tEvery 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_yxreturns 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
Selkie::Test::Keys โ simulate events before taking a snapshot
Selkie::Test::Focus โ focus-gated rendering paths
sub fopen
sub fopen(
Str $,
Str $
) returns NativeCall::Types::PointerMarker 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 StrRender 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 MuTest 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.