Skip to content

Commit 2c9c967

Browse files
authored
Merge pull request #5344 from gui-cs/release/v2.2.0
Release v2.2.0
2 parents 2207a81 + 19c3b57 commit 2c9c967

8 files changed

Lines changed: 231 additions & 8 deletions

File tree

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
*.sh text eol=lf
99
*.ps1 text eol=lf
1010

11+
# ANSI snapshot goldens: raw escape-sequence streams with LF row breaks.
12+
# Must NOT be EOL-normalized or the byte-exact compare + `cat` fidelity breaks.
13+
*.ans binary
14+
1115
# Denote all files that are truly binary and should not be modified.
1216
*.png binary
1317
*.jpg binary

GitVersion.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ branches:
3434
# Matches the main branch
3535
regex: ^main$
3636
# Uses an empty label for stable releases
37-
label: rc
37+
label: ''
3838
# Increments patch version (x.y.z+1) on commits
3939
increment: Patch
4040
# Specifies develop as the source branch

Terminal.Gui/Drivers/Output/OutputBase.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,10 +400,14 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer,
400400
lastUrl = null;
401401
}
402402

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

482-
output.AppendLine ();
486+
// Fixed '\n' (not Environment.NewLine) — keep legacy-console output portable too.
487+
output.Append ('\n');
483488
}
484489

485490
return output.ToString ();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace AppTestHelpers;
2+
3+
/// <summary>
4+
/// Thrown by <see cref="AppTestHelper.AssertAnsiSnapshot" /> when the rendered screen does
5+
/// not match the recorded golden. Deliberately framework-agnostic (no xunit/nunit
6+
/// dependency) — any test runner reports a thrown exception as a failure.
7+
/// </summary>
8+
public sealed class AnsiSnapshotException : Exception
9+
{
10+
/// <inheritdoc cref="AnsiSnapshotException" />
11+
public AnsiSnapshotException (string message) : base (message) { }
12+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using System.Runtime.CompilerServices;
2+
using System.Text;
3+
4+
namespace AppTestHelpers;
5+
6+
public partial class AppTestHelper
7+
{
8+
/// <summary>
9+
/// Asserts the current screen against a golden <b>ANSI</b> snapshot file.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// Captures the screen via <c>IDriver.ToAnsi ()</c> — the exact escape-sequence stream
14+
/// the driver would write to recreate it (truecolor, bold, reverse, blink, layout),
15+
/// excluding the terminal cursor (a separate, non-deterministic <c>SetCursor</c>, so
16+
/// snapshots stay stable). Row separators are normalized to <c>\n</c> so the same
17+
/// golden compares on every platform. The recorded <c>.ans</c> file <i>is</i> the look:
18+
/// <c>cat &lt;file&gt;.ans</c> in a truecolor terminal reproduces the screen exactly.
19+
/// </para>
20+
/// <para>
21+
/// Complements <see cref="AnsiScreenShot" /> (which only dumps to a writer): this
22+
/// records on first run (or when the <c>UPDATE_SNAPSHOTS</c> environment variable is
23+
/// <c>1</c>/<c>true</c>) and otherwise compares byte-for-byte. On mismatch it writes a
24+
/// sibling <c>.ans.actual</c> and throws with the plain-text render inline plus the
25+
/// <c>cat</c> commands — enough to verify the look without an interactive run. Set
26+
/// <c>SNAPSHOT_DIR</c> to override the golden root (default: <c>__snapshots__/</c>
27+
/// beside the calling test source).
28+
/// </para>
29+
/// </remarks>
30+
/// <param name="name">Snapshot name, unique within the test (becomes <c>&lt;name&gt;.ans</c>).</param>
31+
/// <param name="callerFile">Compiler-supplied; locates <c>__snapshots__/</c> beside the test.</param>
32+
/// <returns>This <see cref="AppTestHelper" /> (fluent).</returns>
33+
public AppTestHelper AssertAnsiSnapshot (string name, [CallerFilePath] string callerFile = "")
34+
{
35+
ArgumentException.ThrowIfNullOrWhiteSpace (name);
36+
37+
string? ansi = null;
38+
string? plain = null;
39+
40+
WaitIteration (app =>
41+
{
42+
ansi = app.Driver?.ToAnsi ();
43+
plain = app.Driver?.ToString ();
44+
});
45+
46+
ansi = NormalizeAnsiLineEndings (ansi ?? string.Empty);
47+
48+
string dir = SnapshotDirectory (callerFile);
49+
Directory.CreateDirectory (dir);
50+
string path = Path.Combine (dir, name + ".ans");
51+
52+
bool update = Environment.GetEnvironmentVariable ("UPDATE_SNAPSHOTS") is "1" or "true";
53+
54+
if (update || !File.Exists (path))
55+
{
56+
// Byte-exact, UTF-8 without BOM, no newline translation: the file must remain a
57+
// faithful `cat`-able reproduction of the terminal stream. Mark *.ans `binary` in
58+
// .gitattributes so core.autocrlf cannot corrupt it.
59+
File.WriteAllText (path, ansi, new UTF8Encoding (false));
60+
61+
return this;
62+
}
63+
64+
string expected = NormalizeAnsiLineEndings (File.ReadAllText (path));
65+
66+
if (string.Equals (expected, ansi, StringComparison.Ordinal))
67+
{
68+
return this;
69+
}
70+
71+
string actualPath = path + ".actual";
72+
File.WriteAllText (actualPath, ansi, new UTF8Encoding (false));
73+
74+
AnsiSnapshotException exception = new (
75+
$"""
76+
ANSI snapshot '{name}' did not match {path}.
77+
78+
Plain-text render of the actual screen (glyphs only — colors/styles omitted):
79+
----------------------------------------------------------------------
80+
{plain}
81+
----------------------------------------------------------------------
82+
83+
Exact look (with colors/styles): cat '{actualPath}'
84+
Expected look: cat '{path}'
85+
86+
If this change is intended, accept it by re-running with UPDATE_SNAPSHOTS=1
87+
(or copy the .actual over the .ans).
88+
""");
89+
90+
Stop ();
91+
92+
throw exception;
93+
}
94+
95+
private static string SnapshotDirectory (string callerFile)
96+
{
97+
string? overrideDir = Environment.GetEnvironmentVariable ("SNAPSHOT_DIR");
98+
99+
if (!string.IsNullOrWhiteSpace (overrideDir))
100+
{
101+
return overrideDir;
102+
}
103+
104+
string? sourceDir = Path.GetDirectoryName (callerFile);
105+
106+
if (string.IsNullOrEmpty (sourceDir))
107+
{
108+
throw new InvalidOperationException (
109+
"Could not resolve the snapshot directory from the caller path. Set SNAPSHOT_DIR.");
110+
}
111+
112+
return Path.Combine (sourceDir, "__snapshots__");
113+
}
114+
115+
private static string NormalizeAnsiLineEndings (string ansi) => ansi.Replace ("\r\n", "\n").Replace ("\r", "\n");
116+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using AppTestHelpers;
2+
using Terminal.Gui.Drivers;
3+
4+
namespace IntegrationTests;
5+
6+
/// <summary>
7+
/// Demonstrates <see cref="AppTestHelper.AssertAnsiSnapshot" />: render a screen, capture it
8+
/// as pure ANSI into a golden, compare byte-for-byte thereafter. The recorded
9+
/// <c>__snapshots__/*.ans</c> can be <c>cat</c>'d in a truecolor terminal to see the exact
10+
/// look without an interactive run.
11+
/// </summary>
12+
public class AnsiSnapshotTests (ITestOutputHelper outputHelper)
13+
{
14+
private readonly TextWriter _out = new TestOutputWriter (outputHelper);
15+
16+
[Fact]
17+
public void AssertAnsiSnapshot_Records_Then_Compares ()
18+
{
19+
using AppTestHelper c = With.A<Window> (20, 4, DriverRegistry.Names.ANSI, _out)
20+
.Add (
21+
new Label
22+
{
23+
X = 1,
24+
Y = 1,
25+
Text = "Hello, snapshot!"
26+
})
27+
.WaitIteration ()
28+
.AssertAnsiSnapshot (nameof (AssertAnsiSnapshot_Records_Then_Compares))
29+
.Stop ();
30+
}
31+
32+
// Copilot
33+
[Fact]
34+
public void AssertAnsiSnapshot_Mismatch_Stops_App_Before_Throwing ()
35+
{
36+
string oldSnapshotDir = Environment.GetEnvironmentVariable ("SNAPSHOT_DIR") ?? string.Empty;
37+
string oldUpdateSnapshots = Environment.GetEnvironmentVariable ("UPDATE_SNAPSHOTS") ?? string.Empty;
38+
string snapshotDir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ("N"));
39+
string snapshotName = nameof (AssertAnsiSnapshot_Mismatch_Stops_App_Before_Throwing);
40+
AppTestHelper? c = null;
41+
42+
try
43+
{
44+
Directory.CreateDirectory (snapshotDir);
45+
Environment.SetEnvironmentVariable ("SNAPSHOT_DIR", snapshotDir);
46+
Environment.SetEnvironmentVariable ("UPDATE_SNAPSHOTS", null);
47+
File.WriteAllText (Path.Combine (snapshotDir, snapshotName + ".ans"), "not the current screen");
48+
49+
c = With.A<Window> (20, 4, DriverRegistry.Names.ANSI, _out)
50+
.Add (
51+
new Label
52+
{
53+
X = 1,
54+
Y = 1,
55+
Text = "Hello, snapshot!"
56+
})
57+
.WaitIteration ();
58+
59+
AnsiSnapshotException exception = Assert.Throws<AnsiSnapshotException> (() => c.AssertAnsiSnapshot (snapshotName));
60+
61+
Assert.Contains ("did not match", exception.Message);
62+
Assert.True (c.Finished);
63+
}
64+
finally
65+
{
66+
Environment.SetEnvironmentVariable ("SNAPSHOT_DIR", string.IsNullOrEmpty (oldSnapshotDir) ? null : oldSnapshotDir);
67+
Environment.SetEnvironmentVariable ("UPDATE_SNAPSHOTS", string.IsNullOrEmpty (oldUpdateSnapshots) ? null : oldUpdateSnapshots);
68+
69+
if (c is { Finished: false })
70+
{
71+
c.Dispose ();
72+
}
73+
74+
if (Directory.Exists (snapshotDir))
75+
{
76+
Directory.Delete (snapshotDir, true);
77+
}
78+
}
79+
}
80+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
┌──────────────────┐
2+
│ │
3+
│ Hello, snapshot! │
4+
└──────────────────┘

Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ public void ToAnsi_SingleCell_NoAttribute_ReturnsGraphemeAndNewline ()
1717
buffer.AddStr ("A");
1818
string ansi = output.ToAnsi (buffer);
1919

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

2425
[Theory]
@@ -72,8 +73,9 @@ public void ToAnsi_WithAttribute_AppendsCorrectColorSequence_BasedOnIsLegacyCons
7273
Assert.DoesNotContain ('\u001b', ansi);
7374
}
7475

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

7880
driver.Dispose ();
7981
}

0 commit comments

Comments
 (0)