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 @@ +┌──────────────────┐ +│ │ +│ 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 (); }