diff --git a/.gitattributes b/.gitattributes
index bed4b064a7..0669259a43 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs
index b05fff02bc..8c66057aba 100644
--- a/Terminal.Gui/Drivers/Output/OutputBase.cs
+++ b/Terminal.Gui/Drivers/Output/OutputBase.cs
@@ -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');
}
}
}
@@ -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 ();
diff --git a/Tests/AppTestHelpers/AnsiSnapshotException.cs b/Tests/AppTestHelpers/AnsiSnapshotException.cs
new file mode 100644
index 0000000000..0594cb800b
--- /dev/null
+++ b/Tests/AppTestHelpers/AnsiSnapshotException.cs
@@ -0,0 +1,12 @@
+namespace AppTestHelpers;
+
+///
+/// Thrown by 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.
+///
+public sealed class AnsiSnapshotException : Exception
+{
+ ///
+ public AnsiSnapshotException (string message) : base (message) { }
+}
diff --git a/Tests/AppTestHelpers/AppTestHelper.Snapshot.cs b/Tests/AppTestHelpers/AppTestHelper.Snapshot.cs
new file mode 100644
index 0000000000..8b8fc65e56
--- /dev/null
+++ b/Tests/AppTestHelpers/AppTestHelper.Snapshot.cs
@@ -0,0 +1,116 @@
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace AppTestHelpers;
+
+public partial class AppTestHelper
+{
+ ///
+ /// Asserts the current screen against a golden ANSI snapshot file.
+ ///
+ ///
+ ///
+ /// Captures the screen via IDriver.ToAnsi () — 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 SetCursor, so
+ /// snapshots stay stable). Row separators are normalized to \n so the same
+ /// golden compares on every platform. The recorded .ans file is the look:
+ /// cat <file>.ans in a truecolor terminal reproduces the screen exactly.
+ ///
+ ///
+ /// Complements (which only dumps to a writer): this
+ /// records on first run (or when the UPDATE_SNAPSHOTS environment variable is
+ /// 1/true) and otherwise compares byte-for-byte. On mismatch it writes a
+ /// sibling .ans.actual and throws with the plain-text render inline plus the
+ /// cat commands — enough to verify the look without an interactive run. Set
+ /// SNAPSHOT_DIR to override the golden root (default: __snapshots__/
+ /// beside the calling test source).
+ ///
+ ///
+ /// Snapshot name, unique within the test (becomes <name>.ans).
+ /// Compiler-supplied; locates __snapshots__/ beside the test.
+ /// This (fluent).
+ 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));
+
+ AnsiSnapshotException exception = new (
+ $"""
+ 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).
+ """);
+
+ Stop ();
+
+ throw exception;
+ }
+
+ 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");
+}
diff --git a/Tests/IntegrationTests/FluentTests/AnsiSnapshotTests.cs b/Tests/IntegrationTests/FluentTests/AnsiSnapshotTests.cs
new file mode 100644
index 0000000000..e436fbd1c9
--- /dev/null
+++ b/Tests/IntegrationTests/FluentTests/AnsiSnapshotTests.cs
@@ -0,0 +1,80 @@
+using AppTestHelpers;
+using Terminal.Gui.Drivers;
+
+namespace IntegrationTests;
+
+///
+/// Demonstrates : render a screen, capture it
+/// as pure ANSI into a golden, compare byte-for-byte thereafter. The recorded
+/// __snapshots__/*.ans can be cat'd in a truecolor terminal to see the exact
+/// look without an interactive run.
+///
+public class AnsiSnapshotTests (ITestOutputHelper outputHelper)
+{
+ private readonly TextWriter _out = new TestOutputWriter (outputHelper);
+
+ [Fact]
+ public void AssertAnsiSnapshot_Records_Then_Compares ()
+ {
+ using AppTestHelper c = With.A (20, 4, DriverRegistry.Names.ANSI, _out)
+ .Add (
+ new Label
+ {
+ X = 1,
+ Y = 1,
+ Text = "Hello, snapshot!"
+ })
+ .WaitIteration ()
+ .AssertAnsiSnapshot (nameof (AssertAnsiSnapshot_Records_Then_Compares))
+ .Stop ();
+ }
+
+ // Copilot
+ [Fact]
+ public void AssertAnsiSnapshot_Mismatch_Stops_App_Before_Throwing ()
+ {
+ string oldSnapshotDir = Environment.GetEnvironmentVariable ("SNAPSHOT_DIR") ?? string.Empty;
+ string oldUpdateSnapshots = Environment.GetEnvironmentVariable ("UPDATE_SNAPSHOTS") ?? string.Empty;
+ string snapshotDir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ("N"));
+ string snapshotName = nameof (AssertAnsiSnapshot_Mismatch_Stops_App_Before_Throwing);
+ AppTestHelper? c = null;
+
+ try
+ {
+ Directory.CreateDirectory (snapshotDir);
+ Environment.SetEnvironmentVariable ("SNAPSHOT_DIR", snapshotDir);
+ Environment.SetEnvironmentVariable ("UPDATE_SNAPSHOTS", null);
+ File.WriteAllText (Path.Combine (snapshotDir, snapshotName + ".ans"), "not the current screen");
+
+ c = With.A (20, 4, DriverRegistry.Names.ANSI, _out)
+ .Add (
+ new Label
+ {
+ X = 1,
+ Y = 1,
+ Text = "Hello, snapshot!"
+ })
+ .WaitIteration ();
+
+ AnsiSnapshotException exception = Assert.Throws (() => c.AssertAnsiSnapshot (snapshotName));
+
+ Assert.Contains ("did not match", exception.Message);
+ Assert.True (c.Finished);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable ("SNAPSHOT_DIR", string.IsNullOrEmpty (oldSnapshotDir) ? null : oldSnapshotDir);
+ Environment.SetEnvironmentVariable ("UPDATE_SNAPSHOTS", string.IsNullOrEmpty (oldUpdateSnapshots) ? null : oldUpdateSnapshots);
+
+ if (c is { Finished: false })
+ {
+ c.Dispose ();
+ }
+
+ if (Directory.Exists (snapshotDir))
+ {
+ Directory.Delete (snapshotDir, true);
+ }
+ }
+ }
+}
diff --git a/Tests/IntegrationTests/FluentTests/__snapshots__/AssertAnsiSnapshot_Records_Then_Compares.ans b/Tests/IntegrationTests/FluentTests/__snapshots__/AssertAnsiSnapshot_Records_Then_Compares.ans
new file mode 100644
index 0000000000..169b4f9fb4
--- /dev/null
+++ b/Tests/IntegrationTests/FluentTests/__snapshots__/AssertAnsiSnapshot_Records_Then_Compares.ans
@@ -0,0 +1,4 @@
+[39m[49m┌──────────────────┐
+│ │
+│ Hello, snapshot! │
+└──────────────────┘
diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs
index ed3dd638a9..5c92009c0f 100644
--- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs
@@ -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]
@@ -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 ();
}