Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
*.sh text eol=lf
*.ps1 text eol=lf

# ANSI snapshot goldens: raw escape-sequence streams with LF row breaks.
# Must NOT be EOL-normalized or the byte-exact compare + `cat` fidelity breaks.
*.ans binary

# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
Expand Down
11 changes: 8 additions & 3 deletions Terminal.Gui/Drivers/Output/OutputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,14 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer,
lastUrl = null;
}

// Add newline at end of row if requested
// Add newline at end of row if requested. Use a fixed '\n', NOT
// StringBuilder.AppendLine () / Environment.NewLine: ToAnsi produces a portable
// escape-sequence stream that must be byte-identical regardless of the OS it ran
// on (golden snapshots, cross-platform diffing). Terminals map LF -> CRLF via the
// ONLCR tty discipline, so a '\n' row break still recreates the screen correctly.
if (addNewlines)
{
output.AppendLine ();
output.Append ('\n');
}
}
}
Expand Down Expand Up @@ -479,7 +483,8 @@ public string ToAnsi (IOutputBuffer buffer)
}
}

output.AppendLine ();
// Fixed '\n' (not Environment.NewLine) — keep legacy-console output portable too.
output.Append ('\n');
}

return output.ToString ();
Expand Down
12 changes: 12 additions & 0 deletions Tests/AppTestHelpers/AnsiSnapshotException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace AppTestHelpers;

/// <summary>
/// Thrown by <see cref="AppTestHelper.AssertAnsiSnapshot" /> when the rendered screen does
/// not match the recorded golden. Deliberately framework-agnostic (no xunit/nunit
/// dependency) — any test runner reports a thrown exception as a failure.
/// </summary>
public sealed class AnsiSnapshotException : Exception
{
/// <inheritdoc cref="AnsiSnapshotException" />
public AnsiSnapshotException (string message) : base (message) { }
}
112 changes: 112 additions & 0 deletions Tests/AppTestHelpers/AppTestHelper.Snapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Runtime.CompilerServices;
using System.Text;

namespace AppTestHelpers;

public partial class AppTestHelper
{
/// <summary>
/// Asserts the current screen against a golden <b>ANSI</b> snapshot file.
/// </summary>
/// <remarks>
/// <para>
/// Captures the screen via <c>IDriver.ToAnsi ()</c> — the exact escape-sequence stream
/// the driver would write to recreate it (truecolor, bold, reverse, blink, layout),
/// excluding the terminal cursor (a separate, non-deterministic <c>SetCursor</c>, so
/// snapshots stay stable). Row separators are normalized to <c>\n</c> so the same
/// golden compares on every platform. The recorded <c>.ans</c> file <i>is</i> the look:
/// <c>cat &lt;file&gt;.ans</c> in a truecolor terminal reproduces the screen exactly.
/// </para>
/// <para>
/// Complements <see cref="AnsiScreenShot" /> (which only dumps to a writer): this
/// records on first run (or when the <c>UPDATE_SNAPSHOTS</c> environment variable is
/// <c>1</c>/<c>true</c>) and otherwise compares byte-for-byte. On mismatch it writes a
/// sibling <c>.ans.actual</c> and throws with the plain-text render inline plus the
/// <c>cat</c> commands — enough to verify the look without an interactive run. Set
/// <c>SNAPSHOT_DIR</c> to override the golden root (default: <c>__snapshots__/</c>
/// beside the calling test source).
/// </para>
/// </remarks>
/// <param name="name">Snapshot name, unique within the test (becomes <c>&lt;name&gt;.ans</c>).</param>
/// <param name="callerFile">Compiler-supplied; locates <c>__snapshots__/</c> beside the test.</param>
/// <returns>This <see cref="AppTestHelper" /> (fluent).</returns>
public AppTestHelper AssertAnsiSnapshot (string name, [CallerFilePath] string callerFile = "")
{
ArgumentException.ThrowIfNullOrWhiteSpace (name);

string? ansi = null;
string? plain = null;

WaitIteration (app =>
{
ansi = app.Driver?.ToAnsi ();
plain = app.Driver?.ToString ();
});

ansi = NormalizeAnsiLineEndings (ansi ?? string.Empty);

string dir = SnapshotDirectory (callerFile);
Directory.CreateDirectory (dir);
string path = Path.Combine (dir, name + ".ans");

bool update = Environment.GetEnvironmentVariable ("UPDATE_SNAPSHOTS") is "1" or "true";

if (update || !File.Exists (path))
{
// Byte-exact, UTF-8 without BOM, no newline translation: the file must remain a
// faithful `cat`-able reproduction of the terminal stream. Mark *.ans `binary` in
// .gitattributes so core.autocrlf cannot corrupt it.
File.WriteAllText (path, ansi, new UTF8Encoding (false));

return this;
}

string expected = NormalizeAnsiLineEndings (File.ReadAllText (path));

if (string.Equals (expected, ansi, StringComparison.Ordinal))
{
return this;
}

string actualPath = path + ".actual";
File.WriteAllText (actualPath, ansi, new UTF8Encoding (false));

throw new AnsiSnapshotException (
Comment thread
tig marked this conversation as resolved.
Outdated
$"""
ANSI snapshot '{name}' did not match {path}.

Plain-text render of the actual screen (glyphs only — colors/styles omitted):
----------------------------------------------------------------------
{plain}
----------------------------------------------------------------------

Exact look (with colors/styles): cat '{actualPath}'
Expected look: cat '{path}'

If this change is intended, accept it by re-running with UPDATE_SNAPSHOTS=1
(or copy the .actual over the .ans).
""");
}

private static string SnapshotDirectory (string callerFile)
{
string? overrideDir = Environment.GetEnvironmentVariable ("SNAPSHOT_DIR");

if (!string.IsNullOrWhiteSpace (overrideDir))
{
return overrideDir;
}

string? sourceDir = Path.GetDirectoryName (callerFile);

if (string.IsNullOrEmpty (sourceDir))
{
throw new InvalidOperationException (
"Could not resolve the snapshot directory from the caller path. Set SNAPSHOT_DIR.");
}

return Path.Combine (sourceDir, "__snapshots__");
}

private static string NormalizeAnsiLineEndings (string ansi) => ansi.Replace ("\r\n", "\n").Replace ("\r", "\n");
}
31 changes: 31 additions & 0 deletions Tests/IntegrationTests/FluentTests/AnsiSnapshotTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using AppTestHelpers;
using Terminal.Gui.Drivers;

namespace IntegrationTests;

/// <summary>
/// Demonstrates <see cref="AppTestHelper.AssertAnsiSnapshot" />: render a screen, capture it
/// as pure ANSI into a golden, compare byte-for-byte thereafter. The recorded
/// <c>__snapshots__/*.ans</c> can be <c>cat</c>'d in a truecolor terminal to see the exact
/// look without an interactive run.
/// </summary>
public class AnsiSnapshotTests (ITestOutputHelper outputHelper)
{
private readonly TextWriter _out = new TestOutputWriter (outputHelper);

[Fact]
public void AssertAnsiSnapshot_Records_Then_Compares ()
{
using AppTestHelper c = With.A<Window> (20, 4, DriverRegistry.Names.ANSI, _out)
.Add (
new Label
{
X = 1,
Y = 1,
Text = "Hello, snapshot!"
})
.WaitIteration ()
.AssertAnsiSnapshot (nameof (AssertAnsiSnapshot_Records_Then_Compares))
.Stop ();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
┌──────────────────┐
│ │
│ Hello, snapshot! │
└──────────────────┘
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ public void ToAnsi_SingleCell_NoAttribute_ReturnsGraphemeAndNewline ()
buffer.AddStr ("A");
string ansi = output.ToAnsi (buffer);

// Assert: single grapheme plus newline (BuildAnsiForRegion appends a newline per row)
Assert.Contains ("A" + Environment.NewLine, ansi);
// Assert: single grapheme plus a fixed '\n' row break. ToAnsi is platform-independent
// by contract — it must NOT emit Environment.NewLine.
Assert.Contains ("A\n", ansi);
}

[Theory]
Expand Down Expand Up @@ -72,8 +73,9 @@ public void ToAnsi_WithAttribute_AppendsCorrectColorSequence_BasedOnIsLegacyCons
Assert.DoesNotContain ('\u001b', ansi);
}

// Grapheme and newline should always be present
Assert.Contains ("X" + Environment.NewLine, ansi);
// Grapheme and a fixed '\n' row break should always be present (ToAnsi is portable;
// it must NOT emit Environment.NewLine).
Assert.Contains ("X\n", ansi);

driver.Dispose ();
}
Expand Down
Loading