diff --git a/README.md b/README.md
index eb53ac5..fd59a9a 100644
--- a/README.md
+++ b/README.md
@@ -63,9 +63,43 @@ OPTIONS:
> dotnet verify reject
```
+## Inline snapshots
+
+An [inline snapshot](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md) keeps its expected text in the test source, as a string literal beside the code that produces it, instead of in a `.verified.` file. `review`, `accept` and `reject` all handle them beside file snapshots, so a run that produced both is dealt with in one pass. Accepting one rewrites the literal in the source file rather than moving a file, and `review` shows it as `(inline)`, headed by the call site rather than by a file name:
+
+```
+────────────────────────────────────────────────────────────────────────
+SampleTests.cs:42 (inline)
+────────────────────────────────────────────────────────────────────────
+-old snapshot
++new snapshot
+```
+
+### Where pending inline snapshots come from
+
+Nothing is written to disk for a pending inline snapshot. The test run hands its patch to whichever process owns the inline queue, and only stages it under `obj/VerifyInline/` when nothing answers. So both are read:
+
+```mermaid
+flowchart TD
+ Start["A pending inline snapshot"] --> Owner{"Does a process own
the inline queue?"}
+ Owner -->|yes| Queued["Read from the queue.
Accepting asks the owner to apply it"]
+ Owner -->|no| Staged["Read from obj/VerifyInline/.
Accepting applies the patch here"]
+```
+
+Accepting through the owner rather than here is what keeps one writer per source file, and leaves the tray, the viewer and this tool agreeing about what is still pending. The owner is [DiffEngineTray](https://github.com/VerifyTests/DiffEngine/blob/main/docs/tray.md) when one is running, and otherwise the [DiffEngineViewer](https://github.com/VerifyTests/DiffEngine/blob/main/docs/viewer.md) a test run launched.
+
+Staged snapshots live in the intermediate (`obj`) directory of the test project, so, as with [recorded pairings](#recorded-pairings), the working directory has to contain `obj`.
+
+### Snapshots that cannot be accepted
+
+A snapshot that refuses says why, and does not stop the rest of the run being processed. Two cases:
+
+ * **Conflicting snapshots.** A multi targeted run whose frameworks disagreed about the content has one snapshot per framework for the same call site. Only the first can be shown, so accepting is refused rather than picking between them silently. Resolve it in DiffEngineViewer, or re-run the tests so the frameworks agree.
+ * **The call site could not be found.** The literal is located by content, so an edit that moves it is fine, but one that changes or removes the `Snapshot(...)` call means the patch no longer matches anything. Re-run the test and accept again.
+
## How snapshots are paired
-Accepting a snapshot means moving a `.received.` file over the `.verified.` file it belongs to. Those two names are not always the same, so the tool has to work out which verified file each received file maps to. For example, a multi targeted project puts the runtime and version on the received name only:
+Accepting a file snapshot means moving a `.received.` file over the `.verified.` file it belongs to. Those two names are not always the same, so the tool has to work out which verified file each received file maps to. For example, a multi targeted project puts the runtime and version on the received name only:
```
MyTests.MyTest.DotNet11_0.received.txt -> MyTests.MyTest.verified.txt
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 022ccb1..23140f9 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -3,6 +3,7 @@
true
+
@@ -13,10 +14,11 @@
-
-
+
+
+
-
+
\ No newline at end of file
diff --git a/src/Shared/DeadInlineQueue.cs b/src/Shared/DeadInlineQueue.cs
new file mode 100644
index 0000000..bfaa8c3
--- /dev/null
+++ b/src/Shared/DeadInlineQueue.cs
@@ -0,0 +1,40 @@
+namespace Verify.Terminal.Testing;
+
+///
+/// Points every inline queue exchange this process makes at a port nothing listens on, so a test is
+/// refused instantly instead of reaching whatever tray or viewer happens to be running on the
+/// machine - sending it patches, tracked moves and settles for a test's throwaway directory.
+///
+///
+/// Compiled into both test projects, because both make those exchanges: the integration suite drives
+/// real Verify, and a unit test that applies a staged patch settles it with the owner afterwards.
+/// The variable is process wide and DiffEngine reads it on every call, so this is done once through
+/// a module initializer, before anything a test does. A scenario that wants an owner stands its own
+/// up and points the variable at that instead, then puts this back.
+///
+public static class DeadInlineQueue
+{
+ ///
+ /// Where DiffEngine's clients look for the process owning the inline queue. Internal to
+ /// DiffEngine but read from the environment on every call, so a test can point every client in
+ /// this process somewhere of its own. If DiffEngine renames it these tests break loudly, which
+ /// is the kind of assumption this suite exists to pin.
+ ///
+ public const string PortVariable = "DiffEngine_ViewerPort";
+
+ [ModuleInitializer]
+ internal static void Point() =>
+ System.Environment.SetEnvironmentVariable(PortVariable, DeadPort().ToString());
+
+ // A loopback port with nothing behind it: bound to let the OS pick a free one, then released.
+ // Something else could bind it afterwards, but this is a test machine and a port just handed out
+ // is not one the OS hands out again in a hurry.
+ private static int DeadPort()
+ {
+ var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint) listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
diff --git a/src/Verify.Terminal.IntegrationTests/GlobalUsings.cs b/src/Verify.Terminal.IntegrationTests/GlobalUsings.cs
index 20295b4..6514d2d 100644
--- a/src/Verify.Terminal.IntegrationTests/GlobalUsings.cs
+++ b/src/Verify.Terminal.IntegrationTests/GlobalUsings.cs
@@ -2,6 +2,8 @@
global using Shouldly;
global using Spectre.IO;
global using Verify.Terminal;
+global using System.Runtime.CompilerServices;
+global using Verify.Terminal.Testing;
global using VerifyTests;
global using VerifyXunit;
global using Xunit;
diff --git a/src/Verify.Terminal.IntegrationTests/Harness.cs b/src/Verify.Terminal.IntegrationTests/Harness.cs
index 8fe1199..3cf2707 100644
--- a/src/Verify.Terminal.IntegrationTests/Harness.cs
+++ b/src/Verify.Terminal.IntegrationTests/Harness.cs
@@ -1,16 +1,38 @@
namespace Verify.Terminal.IntegrationTests;
// An isolated temp directory that real Verify writes into and the real SnapshotFinder scans.
+//
+// Nothing here reaches a real tray or viewer: DeadInlineQueue points every queue exchange this
+// process makes at a port nothing listens on, before any scenario runs. A scenario that wants an
+// owner stands its own up (InlineQueueHost), which overrides that for as long as it is alive.
public sealed class Harness : IDisposable
{
private readonly string _directory;
+ // DiffEngine reads this ahead of anything set in process, so a machine with it set overrides
+ // MaxInstancesToLaunch entirely. Which is how these tests came to open diff windows.
+ private const string MaxInstancesVariable = "DiffEngine_MaxInstances";
+
+ // All process wide, so they are put back in Dispose rather than left set for whatever runs next.
+ // Captured rather than assumed, since DiffEngine and the machine decide them between them.
+ private readonly bool _diffDisabled = DiffEngine.DiffRunner.Disabled;
+ private readonly string? _inlineViewer =
+ System.Environment.GetEnvironmentVariable(DiffEngine.DiffRunner.InlineViewerVariable);
+ private readonly string? _maxInstances =
+ System.Environment.GetEnvironmentVariable(MaxInstancesVariable);
+
public Harness(string name)
{
// Verify writes no received maps on a build server, so force it off to keep these scenarios
// deterministic locally and on CI. The assembly disables test parallelization, so this is safe.
DiffEngine.BuildServerDetector.Detected = false;
+ // Nothing here launches a diff tool. The environment variable is what actually decides it,
+ // so setting it in process alone is not enough; the call after it is what drops DiffEngine's
+ // cached value so the new one is read.
+ System.Environment.SetEnvironmentVariable(MaxInstancesVariable, "0");
+ DiffEngine.DiffRunner.MaxInstancesToLaunch(0);
+
_directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"verify-terminal-it",
@@ -41,11 +63,11 @@ public int PublishMaps()
foreach (var file in System.IO.Directory.GetFiles(source))
{
- var lines = System.IO.File.ReadAllLines(file);
+ var lines = File.ReadAllLines(file);
if (lines.Length > 0 &&
lines[0].StartsWith(_directory, StringComparison.OrdinalIgnoreCase))
{
- System.IO.File.Copy(file, System.IO.Path.Combine(target, System.IO.Path.GetFileName(file)), true);
+ File.Copy(file, System.IO.Path.Combine(target, System.IO.Path.GetFileName(file)), true);
copied++;
}
}
@@ -63,8 +85,106 @@ public VerifySettings CreateSettings()
return settings;
}
+ // Inline snapshots are the one thing here that cannot have diff off. Staging is the tail of the
+ // diff path, so `DisableDiff` leaves nothing on disk to find, and DiffEngine switches diff off
+ // by itself on a build server, under continuous testing and under an AI CLI. So it is turned on
+ // for these scenarios only, and put back in Dispose: the file snapshot tests keep the diff path
+ // they have always had, which is none.
+ public VerifySettings CreateInlineSettings(bool useQueue = false)
+ {
+ DiffEngine.DiffRunner.Disabled = false;
+
+ // Being on that path would otherwise mean depending on whether a viewer happens to be
+ // running. The default keeps an inline snapshot on the staging fallback; a scenario that
+ // stood up its own queue owner (InlineQueueHost) opts back in, so its patch crosses the
+ // socket exactly as it would to a tray or viewer.
+ System.Environment.SetEnvironmentVariable(
+ DiffEngine.DiffRunner.InlineViewerVariable,
+ useQueue ? "true" : "false");
+
+ var settings = new VerifySettings();
+ settings.UseDirectory(_directory);
+ settings.DisableRequireUniquePrefix();
+ return settings;
+ }
+
+ // An inline snapshot lives in a string literal in the test source, so a scenario needs a source
+ // file of its own to hold one.
+ public string CreateSource(string content)
+ {
+ var path = System.IO.Path.Combine(_directory, "SampleTests.cs");
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ public string ReadSource() =>
+ File.ReadAllText(System.IO.Path.Combine(_directory, "SampleTests.cs"));
+
+ // Verify stages inline snapshots in this test project's obj directory, but a real run scans a
+ // root that contains obj. So copy this scenario's staged files under the harness directory to
+ // match that layout. Returns how many were copied, so a test can assert that staging really
+ // happened rather than silently finding nothing.
+ public int PublishInline()
+ {
+ var copied = 0;
+ var source = System.IO.Path.Combine(
+ AttributeReader.GetIntermediateDirectory(typeof(Harness).Assembly),
+ InlineSnapshotFinder.StagingDirectoryName);
+ if (!System.IO.Directory.Exists(source))
+ {
+ return copied;
+ }
+
+ var target = System.IO.Path.Combine(_directory, "obj", InlineSnapshotFinder.StagingDirectoryName);
+ System.IO.Directory.CreateDirectory(target);
+
+ foreach (var patch in System.IO.Directory.GetFiles(source, "*.inlinepatch"))
+ {
+ // The patch names the source file it edits, which is what tells this scenario's staging
+ // from that of every other run this project has ever done.
+ if (!File.ReadAllText(patch).Contains(_directory, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // The patch and the two texts beside it, which all share a name.
+ var stem = System.IO.Path.GetFileNameWithoutExtension(patch);
+ foreach (var file in System.IO.Directory.GetFiles(source, $"{stem}.*"))
+ {
+ File.Copy(
+ file,
+ System.IO.Path.Combine(target, System.IO.Path.GetFileName(file)),
+ true);
+ }
+
+ copied++;
+ }
+
+ return copied;
+ }
+
+ // What the harness directory holds once PublishInline has run, which is what a scan reads and
+ // so what still says a snapshot is pending.
+ public IReadOnlyList StagedInlineFileNames()
+ {
+ var directory = System.IO.Path.Combine(
+ _directory,
+ "obj",
+ InlineSnapshotFinder.StagingDirectoryName);
+ if (!System.IO.Directory.Exists(directory))
+ {
+ return [];
+ }
+
+ return System.IO.Directory
+ .GetFiles(directory)
+ .Select(_ => System.IO.Path.GetFileName(_))
+ .Order(StringComparer.Ordinal)
+ .ToList();
+ }
+
public void SeedVerified(string fileName, string content) =>
- System.IO.File.WriteAllText(System.IO.Path.Combine(_directory, fileName), content);
+ File.WriteAllText(System.IO.Path.Combine(_directory, fileName), content);
public IReadOnlyList ReceivedFileNames() =>
System.IO.Directory
@@ -73,20 +193,48 @@ public IReadOnlyList ReceivedFileNames() =>
.ToList();
// Runs the real SnapshotFinder (real globber, real filesystem) over the temp directory.
- public Snapshot FindSingle()
+ public Snapshot FindSingle() => FindFileSnapshots().Single();
+
+ public ISet FindFileSnapshots()
{
var environment = new Spectre.IO.Environment();
var fileSystem = new FileSystem();
var globber = new Globber(fileSystem, environment);
var finder = new SnapshotFinder(globber, environment);
+ return finder.Find(Directory);
+ }
+
+ // Runs the real InlineSnapshotFinder over the temp directory. The real queue is asked as well,
+ // as a real run would, which is safe here because the constructor forced this scenario's
+ // snapshot onto the staging path and the queue is filtered to this directory.
+ public InlineSnapshot FindSingleInline()
+ {
+ var environment = new Spectre.IO.Environment();
+ var fileSystem = new FileSystem();
+ var globber = new Globber(fileSystem, environment);
+ var finder = new InlineSnapshotFinder(globber, environment, fileSystem, new InlineQueueOwner());
return finder.Find(Directory).Single();
}
- public bool Accept(Snapshot snapshot) =>
- new SnapshotManager(new FileSystem()).Accept(snapshot);
+ public SnapshotResult Accept(ISnapshot snapshot) =>
+ CreateManager().Accept(snapshot);
+
+ public SnapshotResult Reject(ISnapshot snapshot) =>
+ CreateManager().Reject(snapshot);
+
+ private static SnapshotManager CreateManager()
+ {
+ var fileSystem = new FileSystem();
+ var inline = new InlineSnapshotManager(fileSystem, new InlineQueueOwner());
+ return new(fileSystem, inline);
+ }
public void Dispose()
{
+ DiffEngine.DiffRunner.Disabled = _diffDisabled;
+ System.Environment.SetEnvironmentVariable(DiffEngine.DiffRunner.InlineViewerVariable, _inlineViewer);
+ System.Environment.SetEnvironmentVariable(MaxInstancesVariable, _maxInstances);
+
try
{
System.IO.Directory.Delete(_directory, recursive: true);
diff --git a/src/Verify.Terminal.IntegrationTests/InlineQueueHost.cs b/src/Verify.Terminal.IntegrationTests/InlineQueueHost.cs
new file mode 100644
index 0000000..45b76a3
--- /dev/null
+++ b/src/Verify.Terminal.IntegrationTests/InlineQueueHost.cs
@@ -0,0 +1,250 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using DiffEngine;
+using Environment = System.Environment;
+
+namespace Verify.Terminal.IntegrationTests;
+
+// A real inline queue owner, standing in for DiffEngineTray or DiffEngineViewer.
+//
+// Hosts DiffEngine's own InlineQueue behind DiffEngine's own wire protocol, on an ephemeral
+// loopback port that DiffEngine_ViewerPort points every in-process client at. So the patch a
+// failing Verify run produces arrives here over the same socket exchange it would arrive at a tray
+// or viewer by, and Verify.Terminal's listing and accept run against an owner that behaves as they
+// do, including applying an accepted patch with InlineApplier.
+//
+// The wire framing is mirrored here because it is internal to DiffEngine: line based, `name: value`,
+// with every value base64. That duplication is deliberate pinning — a protocol change in DiffEngine
+// breaks these tests loudly instead of silently stranding this tool's users.
+public sealed class InlineQueueHost : IDisposable
+{
+ private readonly TcpListener _listener;
+ private readonly CancellationTokenSource _cancellation = new();
+ private readonly Task _loop;
+ private readonly string? _previousPort;
+ private readonly object _gate = new();
+
+ private InlineQueue _queue = InlineQueue.Empty;
+
+ public InlineQueueHost()
+ {
+ // Port 0 asks the OS to choose, so this never collides with a real tray or viewer.
+ _listener = new(IPAddress.Loopback, 0);
+ _listener.Start();
+ var port = ((IPEndPoint) _listener.LocalEndpoint).Port;
+
+ _previousPort = Environment.GetEnvironmentVariable(DeadInlineQueue.PortVariable);
+ Environment.SetEnvironmentVariable(DeadInlineQueue.PortVariable, port.ToString());
+
+ _loop = Task.Run(Listen);
+ }
+
+ public int Count
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _queue.Count;
+ }
+ }
+ }
+
+ private async Task Listen()
+ {
+ while (!_cancellation.IsCancellationRequested)
+ {
+ using var client = await _listener.AcceptTcpClientAsync(_cancellation.Token);
+ await using var stream = client.GetStream();
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ // The client half-closes after writing, so the request is read to its end.
+ var request = await reader.ReadToEndAsync();
+ var response = Handle(request);
+ var bytes = Encoding.UTF8.GetBytes(response);
+ await stream.WriteAsync(bytes, _cancellation.Token);
+ await stream.FlushAsync(_cancellation.Token);
+ }
+ }
+
+ private string Handle(string request)
+ {
+ string? verb = null;
+ string? key = null;
+ string? body = null;
+ var versioned = false;
+
+ foreach (var raw in request.Split('\n'))
+ {
+ var line = raw.TrimEnd('\r');
+ var separator = line.IndexOf(':');
+ if (separator < 1)
+ {
+ continue;
+ }
+
+ var name = line[..separator];
+ var value = line[(separator + 1)..].Trim();
+ switch (name)
+ {
+ case "version":
+ versioned = value == "1";
+ break;
+ case "verb":
+ verb = value;
+ break;
+ case "key":
+ key = Decode(value);
+ break;
+ case "body":
+ body = Decode(value);
+ break;
+ }
+ }
+
+ if (!versioned || verb is null)
+ {
+ return Error("Unreadable request");
+ }
+
+ lock (_gate)
+ {
+ switch (verb)
+ {
+ case "inline":
+ if (body is null ||
+ !InlinePatchFile.TryParse(body, out var patch))
+ {
+ return Error("Unreadable patch");
+ }
+
+ _queue = _queue.Enqueue(patch);
+ return Ok();
+
+ case "settle":
+ if (key is not null)
+ {
+ // The body carries the sending framework, so a multi targeted run only
+ // settles its own variant.
+ _queue = _queue.Settle(key, body);
+ }
+
+ return Ok();
+
+ case "list":
+ return Listing(withPatches: false);
+
+ case "listfull":
+ return Listing(withPatches: true);
+
+ case "accept":
+ {
+ if (key is null ||
+ _queue.Find(key) is null)
+ {
+ // No entry for the key: false with no message, per the owner contract.
+ return Error(null);
+ }
+
+ // The queue itself decides what accepting means, exactly as it does inside the
+ // tray and the viewer: a conflicted entry is refused, and the applier's outcome
+ // decides whether the entry goes or stays with a status.
+ var accepted = _queue.Accept(key, InlineApplier.Apply, out var message);
+ if (ReferenceEquals(accepted, _queue))
+ {
+ // Unchanged with a message is a refusal; nothing was attempted.
+ return Error(message);
+ }
+
+ _queue = accepted;
+ return Ok(message);
+ }
+
+ case "discard":
+ {
+ if (key is null ||
+ _queue.Find(key) is null)
+ {
+ return Error(null);
+ }
+
+ _queue = _queue.Discard(key, out var message);
+ return Ok(message);
+ }
+
+ default:
+ // Focus, window verbs and anything newer: acknowledged, nothing to do.
+ return Ok();
+ }
+ }
+ }
+
+ private string Listing(bool withPatches)
+ {
+ var builder = new StringBuilder("version: 1\nstatus: ok\n");
+ foreach (var entry in _queue.Items)
+ {
+ var status = entry.Status is null ? "" : Encode(entry.Status);
+ var head = $"{Encode(entry.Key)}|{Encode(entry.Name)}|{status}";
+ if (!withPatches)
+ {
+ builder.Append($"item: {head}\n");
+ continue;
+ }
+
+ builder.Append($"full: {head}|{Origins(entry.Variants[0].Origins)}|{Encode(InlinePatchFile.Build(entry.Patch))}\n");
+ foreach (var variant in entry.Variants.Skip(1))
+ {
+ builder.Append($"variant: {Encode(entry.Key)}|{Origins(variant.Origins)}|{Encode(InlinePatchFile.Build(variant.Patch))}\n");
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ private static string Ok(string? message = null)
+ {
+ if (message is null)
+ {
+ return "version: 1\nstatus: ok\n";
+ }
+
+ return $"version: 1\nstatus: ok\nmessage: {Encode(message)}\n";
+ }
+
+ private static string Error(string? message)
+ {
+ if (message is null)
+ {
+ return "version: 1\nstatus: error\n";
+ }
+
+ return $"version: 1\nstatus: error\nmessage: {Encode(message)}\n";
+ }
+
+ private static string Origins(IReadOnlyList origins) =>
+ origins.Count == 0 ? "" : Encode(string.Join(",", origins));
+
+ private static string Encode(string value) =>
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
+
+ private static string? Decode(string value) =>
+ Encoding.UTF8.GetString(Convert.FromBase64String(value));
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable(DeadInlineQueue.PortVariable, _previousPort);
+ _cancellation.Cancel();
+ _listener.Stop();
+ try
+ {
+ _loop.Wait(TimeSpan.FromSeconds(2));
+ }
+ catch (AggregateException)
+ {
+ // Shutdown races are not part of any scenario.
+ }
+
+ _cancellation.Dispose();
+ }
+}
diff --git a/src/Verify.Terminal.IntegrationTests/InlineSnapshotTests.cs b/src/Verify.Terminal.IntegrationTests/InlineSnapshotTests.cs
new file mode 100644
index 0000000..ff004fc
--- /dev/null
+++ b/src/Verify.Terminal.IntegrationTests/InlineSnapshotTests.cs
@@ -0,0 +1,226 @@
+namespace Verify.Terminal.IntegrationTests;
+
+// Covers inline snapshots, where the expected text lives in a string literal in the test source
+// rather than in a `.verified.` file, so accepting rewrites that source instead of moving a file.
+//
+// Drives real Verify until it leaves a real staged patch, then runs the real InlineSnapshotFinder
+// and SnapshotManager over it. The purpose is to pin the assumptions Verify.Terminal makes about
+// how Verify stages an inline snapshot and what accepting one has to produce, so a future Verify
+// that changes either breaks these tests rather than silently misbehaving.
+public class InlineSnapshotTests
+{
+ // Multi line on purpose: a single line literal would pass whatever shape the accept wrote it
+ // in, and the shape is half of what has to hold for the next run to read it back.
+ private const string Value = "line one\nline two";
+
+ [Fact]
+ public async Task ChangedSnapshot_IsStaged_AndAcceptRewritesTheSource()
+ {
+ using var harness = new Harness(nameof(ChangedSnapshot_IsStaged_AndAcceptRewritesTheSource));
+
+ var source = harness.CreateSource(
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot("old snapshot");
+ }
+ """);
+
+ // The call site is passed rather than left to the caller attributes, so the snapshot
+ // belongs to the generated source above instead of to this file.
+ await Fails(harness, _ => _.Snapshot("old snapshot", source, 4, "\"old snapshot\"", "Sample"));
+
+ // Nothing owned a queue, so the patch and its two texts are on disk. The names carry the
+ // framework that produced them, which is what tells one framework's snapshot from another's
+ // when a multi targeted run disagrees with itself.
+ harness.PublishInline().ShouldBe(1);
+ harness.StagedInlineFileNames()
+ .Select(_ => System.IO.Path.GetExtension(_))
+ .ShouldBe([".inlinepatch", ".txt", ".txt"], ignoreOrder: true);
+
+ var snapshot = harness.FindSingleInline();
+ snapshot.SourceFile.ShouldBe(source);
+ snapshot.Line.ShouldBe(4);
+ snapshot.Expected.ShouldBe("old snapshot");
+ snapshot.Received.ShouldBe(Value);
+ snapshot.IsQueued.ShouldBeFalse();
+ snapshot.Conflict.ShouldBeNull();
+ snapshot.Staged.ShouldHaveSingleItem();
+
+ // The staged received text is named like any other received file. It belongs to a snapshot
+ // that lives in a source file, so the file scan has to leave it alone: accepting it as a
+ // file snapshot would rename it to a verified file nothing reads, and leave the real
+ // snapshot pending.
+ harness.FindFileSnapshots().ShouldBeEmpty();
+
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
+
+ // The literal is written as the raw string Verify reads back, which is what makes the next
+ // run pass rather than fail against its own snapshot.
+ harness.ReadSource().ShouldBe(
+ """"
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot(
+ """
+ line one
+ line two
+ """);
+ }
+ """");
+
+ // The staged files are all that would still say the snapshot is pending.
+ harness.StagedInlineFileNames().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task NewSnapshot_IsStaged_AndAcceptWritesTheLiteral()
+ {
+ using var harness = new Harness(nameof(NewSnapshot_IsStaged_AndAcceptWritesTheLiteral));
+
+ // A Snapshot call with no expected argument: the snapshot has never been accepted, so there
+ // is no literal to compare against and accepting writes the first one.
+ var source = harness.CreateSource(
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot();
+ }
+ """);
+
+ await Fails(harness, _ => _.Snapshot(null, source, 4, null, "Sample"));
+
+ harness.PublishInline().ShouldBe(1);
+
+ var snapshot = harness.FindSingleInline();
+
+ // No literal yet, which compares as an empty verified file does.
+ snapshot.Expected.ShouldBeEmpty();
+ snapshot.Received.ShouldBe(Value);
+
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
+
+ harness.ReadSource().ShouldBe(
+ """"
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot(
+ """
+ line one
+ line two
+ """);
+ }
+ """");
+ }
+
+ // The scenario where DiffEngineTray or DiffEngineViewer is running when the test fails: the
+ // patch goes to that process over its socket, nothing is staged on disk, and the user then
+ // reviews and accepts in this tool anyway. So everything this tool shows has to come off the
+ // queue, and the accept has to be carried out by the owner.
+ [Fact]
+ public async Task QueuedSnapshot_IsFoundReviewedAndAcceptedThroughTheOwner()
+ {
+ using var harness = new Harness(nameof(QueuedSnapshot_IsFoundReviewedAndAcceptedThroughTheOwner));
+ using var owner = new InlineQueueHost();
+
+ var source = harness.CreateSource(
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot("old snapshot");
+ }
+ """);
+
+ await Fails(
+ harness,
+ _ => _.Snapshot("old snapshot", source, 4, "\"old snapshot\"", "Sample"),
+ useQueue: true);
+
+ // The owner took the patch over the socket, so the run left nothing on disk. This is the
+ // half that makes the scenario worth pinning: a tool that only scanned obj/VerifyInline/
+ // would report nothing pending here.
+ owner.Count.ShouldBe(1);
+ harness.PublishInline().ShouldBe(0);
+
+ var snapshot = harness.FindSingleInline();
+ snapshot.IsQueued.ShouldBeTrue();
+ snapshot.Staged.ShouldBeEmpty();
+ snapshot.Conflict.ShouldBeNull();
+ snapshot.SourceFile.ShouldBe(source);
+ snapshot.Line.ShouldBe(4);
+
+ // Review: both texts rode the queue listing on the patch, so the diff a review renders
+ // needs nothing from disk.
+ snapshot.Expected.ShouldBe("old snapshot");
+ snapshot.Received.ShouldBe(Value);
+
+ var diff = new SnapshotDiffer(new FileSystem(), new Spectre.IO.Environment()).Diff(snapshot);
+ diff.Old.Select(_ => _.Text).ShouldContain("old snapshot");
+ diff.New.Select(_ => _.Text).ShouldContain("line one");
+
+ // Accept: asked of the owner, which applies the patch and drops the entry, so every
+ // surface agrees the snapshot is no longer pending.
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
+
+ harness.ReadSource().ShouldBe(
+ """"
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot(
+ """
+ line one
+ line two
+ """);
+ }
+ """");
+
+ owner.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task RejectedSnapshot_LeavesTheSourceAlone()
+ {
+ using var harness = new Harness(nameof(RejectedSnapshot_LeavesTheSourceAlone));
+
+ var before =
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot("old snapshot");
+ }
+ """;
+ var source = harness.CreateSource(before);
+
+ await Fails(harness, _ => _.Snapshot("old snapshot", source, 4, "\"old snapshot\"", "Sample"));
+ harness.PublishInline().ShouldBe(1);
+
+ harness.Reject(harness.FindSingleInline()).Succeeded.ShouldBeTrue();
+
+ harness.ReadSource().ShouldBe(before);
+
+ // Rejecting clears the staging, so the snapshot stops being pending without the source
+ // having been touched.
+ harness.StagedInlineFileNames().ShouldBeEmpty();
+ }
+
+ // Runs Verify against the literal the generated source holds, expecting the mismatch (or the
+ // new snapshot) that leaves a patch behind — with the queue owner when the scenario stood one
+ // up, and staged on disk otherwise.
+ private static async Task Fails(Harness harness, Action snapshot, bool useQueue = false)
+ {
+ var settings = harness.CreateInlineSettings(useQueue);
+ settings.UseTypeName("N");
+ settings.UseMethodName("Sample");
+ snapshot(settings);
+
+ var exception = await Record.ExceptionAsync(async () => await Verifier.Verify(Value, settings));
+ exception.ShouldNotBeNull("Verify was expected to fail and leave an inline snapshot pending.");
+ }
+}
diff --git a/src/Verify.Terminal.IntegrationTests/IntegrationTestBase.cs b/src/Verify.Terminal.IntegrationTests/IntegrationTestBase.cs
index ea73e0d..b5f854f 100644
--- a/src/Verify.Terminal.IntegrationTests/IntegrationTestBase.cs
+++ b/src/Verify.Terminal.IntegrationTests/IntegrationTestBase.cs
@@ -52,18 +52,14 @@ async Task Run(bool withMap)
{
using var harness = new Harness(method);
- VerifySettings Settings()
- {
- var settings = harness.CreateSettings();
- settings.UseTypeName("N");
- settings.UseMethodName(method);
- configure(settings);
- return settings;
- }
+ var settings = harness.CreateSettings();
+ settings.UseTypeName("N");
+ settings.UseMethodName(method);
+ configure(settings);
var because = withMap ? "with map" : "without map";
- var correctVerified = await ProduceReceived(Settings());
+ var correctVerified = await ProduceReceived(settings);
// Verify's own verified name matches what Verify.Terminal assumes.
correctVerified.ShouldBe(expectedVerified, because);
@@ -87,10 +83,10 @@ VerifySettings Settings()
var literal = received.Replace(".received.", ".verified.");
snapshot.IsRerouted.ShouldBe(correctVerified != literal, because);
- harness.Accept(snapshot).ShouldBeTrue(because);
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue(because);
// The received value now lives at the correct verified name, so Verify passes.
- (await Verifies(Settings())).ShouldBeTrue(because);
+ (await Verifies(settings)).ShouldBeTrue(because);
}
}
@@ -105,16 +101,12 @@ protected async Task AssertNewSnapshot(
{
using var harness = new Harness(method);
- VerifySettings Settings()
- {
- var settings = harness.CreateSettings();
- settings.UseTypeName("N");
- settings.UseMethodName(method);
- configure(settings);
- return settings;
- }
+ var settings = harness.CreateSettings();
+ settings.UseTypeName("N");
+ settings.UseMethodName(method);
+ configure(settings);
- var correctVerified = await ProduceReceived(Settings());
+ var correctVerified = await ProduceReceived(settings);
correctVerified.ShouldBe(expectedVerified);
var received = harness.ReceivedFileNames().ShouldHaveSingleItem();
@@ -126,9 +118,9 @@ VerifySettings Settings()
System.IO.Path.GetFileName(snapshot.Verified.FullPath).ShouldBe(literal);
snapshot.IsRerouted.ShouldBeFalse();
- harness.Accept(snapshot).ShouldBeTrue();
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
- (await Verifies(Settings())).ShouldBe(expectRoundTrips);
+ (await Verifies(settings)).ShouldBe(expectRoundTrips);
}
// A brand new snapshot, but with Verify's received map available. The map names the verified file,
@@ -140,16 +132,12 @@ protected async Task AssertNewSnapshotWithMap(
{
using var harness = new Harness(method);
- VerifySettings Settings()
- {
- var settings = harness.CreateSettings();
- settings.UseTypeName("N");
- settings.UseMethodName(method);
- configure(settings);
- return settings;
- }
+ var settings = harness.CreateSettings();
+ settings.UseTypeName("N");
+ settings.UseMethodName(method);
+ configure(settings);
- var correctVerified = await ProduceReceived(Settings());
+ var correctVerified = await ProduceReceived(settings);
correctVerified.ShouldBe(expectedVerified);
var received = harness.ReceivedFileNames().ShouldHaveSingleItem();
@@ -161,10 +149,10 @@ VerifySettings Settings()
System.IO.Path.GetFileName(snapshot.Verified.FullPath).ShouldBe(correctVerified);
snapshot.IsRerouted.ShouldBe(correctVerified != received.Replace(".received.", ".verified."));
- harness.Accept(snapshot).ShouldBeTrue();
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
// The accept landed where Verify expects, so the next run passes.
- (await Verifies(Settings())).ShouldBeTrue();
+ (await Verifies(settings)).ShouldBeTrue();
}
private static string ParseVerifiedFileName(string message)
diff --git a/src/Verify.Terminal.IntegrationTests/ParameterNamingTests.cs b/src/Verify.Terminal.IntegrationTests/ParameterNamingTests.cs
index 3357a3c..0954ee2 100644
--- a/src/Verify.Terminal.IntegrationTests/ParameterNamingTests.cs
+++ b/src/Verify.Terminal.IntegrationTests/ParameterNamingTests.cs
@@ -74,7 +74,7 @@ VerifySettings Settings()
System.IO.Path.GetFileName(snapshot.Verified.FullPath).ShouldBe(correctVerified, because);
snapshot.IsRerouted.ShouldBeTrue(because);
- harness.Accept(snapshot).ShouldBeTrue(because);
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue(because);
(await Verifies(Settings())).ShouldBeTrue(because);
}
}
@@ -106,7 +106,7 @@ VerifySettings Settings()
System.IO.Path.GetFileName(snapshot.Verified.FullPath).ShouldBe(correctVerified);
snapshot.IsRerouted.ShouldBeTrue();
- harness.Accept(snapshot).ShouldBeTrue();
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
(await Verifies(Settings())).ShouldBeTrue();
}
@@ -141,7 +141,7 @@ VerifySettings Settings()
System.IO.Path.GetFileName(snapshot.Verified.FullPath).ShouldBe(literal);
snapshot.IsRerouted.ShouldBeFalse();
- harness.Accept(snapshot).ShouldBeTrue();
+ harness.Accept(snapshot).Succeeded.ShouldBeTrue();
// A non-trailing ignored parameter cannot be reconstructed from the received name, so the
// accept lands at the wrong verified file and Verify still fails. This is only reachable
diff --git a/src/Verify.Terminal.IntegrationTests/Verify.Terminal.IntegrationTests.csproj b/src/Verify.Terminal.IntegrationTests/Verify.Terminal.IntegrationTests.csproj
index 9b32d65..a4d9164 100644
--- a/src/Verify.Terminal.IntegrationTests/Verify.Terminal.IntegrationTests.csproj
+++ b/src/Verify.Terminal.IntegrationTests/Verify.Terminal.IntegrationTests.csproj
@@ -12,4 +12,8 @@
+
+
+
+
diff --git a/src/Verify.Terminal.Tests/Expectations/Rendering/RenderConflictedInline.Output.verified.txt b/src/Verify.Terminal.Tests/Expectations/Rendering/RenderConflictedInline.Output.verified.txt
new file mode 100644
index 0000000..260f46f
--- /dev/null
+++ b/src/Verify.Terminal.Tests/Expectations/Rendering/RenderConflictedInline.Output.verified.txt
@@ -0,0 +1,9 @@
+────────────────────────────────────────────────────────────────────────────────
+SampleTests.cs:42 (inline, conflicting: net8.0 / net10.0)
+────────────────────────────────────────────────────────────────────────────────
+-old snapshot
++new snapshot
+───────────┬─┬──────────────────────────────────────────────────────────────────
+ 1 │-│old·snapshot
+ 1 │+│from·net10
+───────────┴─┴──────────────────────────────────────────────────────────────────
\ No newline at end of file
diff --git a/src/Verify.Terminal.Tests/Expectations/Rendering/RenderInline.Output.verified.txt b/src/Verify.Terminal.Tests/Expectations/Rendering/RenderInline.Output.verified.txt
new file mode 100644
index 0000000..f61697b
--- /dev/null
+++ b/src/Verify.Terminal.Tests/Expectations/Rendering/RenderInline.Output.verified.txt
@@ -0,0 +1,11 @@
+────────────────────────────────────────────────────────────────────────────────
+SampleTests.cs:42 (inline)
+────────────────────────────────────────────────────────────────────────────────
+-old snapshot
++new snapshot
+───────────┬─┬──────────────────────────────────────────────────────────────────
+ 1 1 │ │line1
+ 2 │-│line2
+ 2 │+│line2·changed
+ 3 3 │ │line3
+───────────┴─┴──────────────────────────────────────────────────────────────────
\ No newline at end of file
diff --git a/src/Verify.Terminal.Tests/GlobalUsings.cs b/src/Verify.Terminal.Tests/GlobalUsings.cs
index 66493e3..4d564ae 100644
--- a/src/Verify.Terminal.Tests/GlobalUsings.cs
+++ b/src/Verify.Terminal.Tests/GlobalUsings.cs
@@ -1,6 +1,8 @@
global using System.Reflection;
global using System.Runtime.CompilerServices;
+global using DiffEngine;
global using Shouldly;
global using Spectre.Console.Testing;
global using Spectre.IO;
-global using Spectre.IO.Testing;
\ No newline at end of file
+global using Spectre.IO.Testing;
+global using Verify.Terminal.Testing;
\ No newline at end of file
diff --git a/src/Verify.Terminal.Tests/InlineSnapshotFinderTests.cs b/src/Verify.Terminal.Tests/InlineSnapshotFinderTests.cs
new file mode 100644
index 0000000..8009d9f
--- /dev/null
+++ b/src/Verify.Terminal.Tests/InlineSnapshotFinderTests.cs
@@ -0,0 +1,175 @@
+namespace Verify.Terminal.Tests;
+
+public sealed class InlineSnapshotFinderTests
+{
+ [Fact]
+ public void Should_Find_A_Staged_Snapshot()
+ {
+ var fileSystem = CreateFileSystem();
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("new snapshot"));
+
+ var result = Find(fileSystem).ShouldHaveSingleItem();
+
+ result.SourceFile.ShouldBe(InlineTestData.SourceFile);
+ result.Line.ShouldBe(42);
+ result.Expected.ShouldBe("old snapshot");
+ result.Received.ShouldBe("new snapshot");
+ result.IsQueued.ShouldBeFalse();
+ result.Conflict.ShouldBeNull();
+
+ var staged = result.Staged.ShouldHaveSingleItem();
+ staged.Origin.ShouldBe("DotNet10_0");
+ staged.PatchPath.GetFilename().FullPath
+ .ShouldBe("SampleTests.Sample.a1b2c3d4.DotNet10_0.inlinepatch");
+ staged.ReceivedPath.ShouldNotBeNull();
+ staged.ExpectedPath.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Should_Find_A_Staged_Snapshot_Without_Its_Texts()
+ {
+ // The two texts are for looking at, so a snapshot is still reviewable without them: the
+ // patch carries both.
+ var fileSystem = CreateFileSystem();
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("new snapshot"), withTexts: false);
+
+ var staged = Find(fileSystem).ShouldHaveSingleItem().Staged.ShouldHaveSingleItem();
+
+ staged.ReceivedPath.ShouldBeNull();
+ staged.ExpectedPath.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Should_Ignore_A_Staged_Remove()
+ {
+ // A Remove is applied by whoever produced it and is never reviewed.
+ var fileSystem = CreateFileSystem();
+ InlineTestData.Stage(
+ fileSystem,
+ InlineTestData.Patch(string.Empty, mode: InlinePatchMode.Remove));
+
+ Find(fileSystem).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Ignore_An_Unreadable_Patch()
+ {
+ var fileSystem = CreateFileSystem();
+ fileSystem
+ .CreateFile($"{InlineTestData.StagingDirectory}/broken.inlinepatch")
+ .SetTextContent("not a patch");
+
+ Find(fileSystem).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Merge_Staged_Patches_That_Agree()
+ {
+ // A multi targeted run stages one patch per framework. Agreeing frameworks are one snapshot,
+ // with both sets of files to clear once it is dealt with.
+ var fileSystem = CreateFileSystem();
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("new snapshot"), "DotNet8_0");
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("new snapshot"), "DotNet10_0");
+
+ var result = Find(fileSystem).ShouldHaveSingleItem();
+
+ result.Conflict.ShouldBeNull();
+ result.Received.ShouldBe("new snapshot");
+ result.Staged.Select(_ => _.Origin).ShouldBe(["DotNet10_0", "DotNet8_0"], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void Should_Flag_Staged_Patches_That_Disagree()
+ {
+ var fileSystem = CreateFileSystem();
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("from net8"), "DotNet8_0");
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("from net10"), "DotNet10_0");
+
+ var result = Find(fileSystem).ShouldHaveSingleItem();
+
+ result.Conflict.ShouldBe("DotNet10_0 / DotNet8_0");
+ result.Headers.ShouldHaveSingleItem().Note
+ .ShouldBe("(inline, conflicting: DotNet10_0 / DotNet8_0)");
+ }
+
+ [Fact]
+ public void Should_Prefer_The_Queued_Snapshot_Over_The_Staged_One()
+ {
+ // A run can stage its patch and have an owner arrive afterwards. The owner holds the live
+ // state, but the staged files are still there to clear.
+ var fileSystem = CreateFileSystem();
+ InlineTestData.Stage(fileSystem, InlineTestData.Patch("staged"));
+
+ var queue = new FakeInlineQueueOwner();
+ queue.Queue(InlineTestData.Patch("queued"));
+
+ var result = Find(fileSystem, queue).ShouldHaveSingleItem();
+
+ result.IsQueued.ShouldBeTrue();
+ result.Received.ShouldBe("queued");
+ result.Staged.ShouldHaveSingleItem();
+ }
+
+ [Fact]
+ public void Should_Flag_A_Conflicted_Queued_Snapshot()
+ {
+ var queue = new FakeInlineQueueOwner();
+ queue.Queue(
+ Origin(InlineTestData.Patch("from net8"), "net8.0"),
+ Origin(InlineTestData.Patch("from net10"), "net10.0"));
+
+ var result = Find(CreateFileSystem(), queue).ShouldHaveSingleItem();
+
+ result.IsQueued.ShouldBeTrue();
+ result.Conflict.ShouldBe("net8.0 / net10.0");
+ }
+
+ [Fact]
+ public void Should_Ignore_Queued_Snapshots_Outside_The_Root()
+ {
+ // The queue is machine wide: one owner holds the pending snapshots of every solution on it.
+ var queue = new FakeInlineQueueOwner();
+ queue.Queue(InlineTestData.Patch("elsewhere", source: "/Other/src/SampleTests.cs"));
+
+ Find(CreateFileSystem(), queue).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Ignore_A_Queued_Remove()
+ {
+ var queue = new FakeInlineQueueOwner();
+ queue.Queue(InlineTestData.Patch(string.Empty, mode: InlinePatchMode.Remove));
+
+ Find(CreateFileSystem(), queue).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Find_Nothing_When_Nothing_Is_Pending()
+ {
+ Find(CreateFileSystem()).ShouldBeEmpty();
+ }
+
+ private static InlinePatch Origin(InlinePatch patch, string framework)
+ {
+ patch.Framework = framework;
+ return patch;
+ }
+
+ private static FakeFileSystem CreateFileSystem() =>
+ new(new FakeEnvironment(PlatformFamily.Linux));
+
+ private static IReadOnlyList Find(
+ FakeFileSystem fileSystem,
+ IInlineQueueOwner? queue = null)
+ {
+ var environment = new FakeEnvironment(PlatformFamily.Linux);
+ var globber = new Globber(fileSystem, environment);
+ var finder = new InlineSnapshotFinder(
+ globber,
+ environment,
+ fileSystem,
+ queue ?? new FakeInlineQueueOwner());
+
+ return finder.Find();
+ }
+}
diff --git a/src/Verify.Terminal.Tests/InlineSnapshotManagerTests.cs b/src/Verify.Terminal.Tests/InlineSnapshotManagerTests.cs
new file mode 100644
index 0000000..1f561d7
--- /dev/null
+++ b/src/Verify.Terminal.Tests/InlineSnapshotManagerTests.cs
@@ -0,0 +1,312 @@
+namespace Verify.Terminal.Tests;
+
+public sealed class InlineSnapshotManagerTests
+{
+ [Fact]
+ public void Should_Refuse_To_Accept_A_Conflicted_Snapshot()
+ {
+ // Only one of the frameworks' snapshots is rendered, so accepting would be picking between
+ // them without having shown them.
+ var queue = new FakeInlineQueueOwner();
+ var snapshot = new InlineSnapshot(
+ InlineTestData.Patch("new snapshot"),
+ isQueued: true,
+ conflict: "net8.0 / net10.0");
+
+ var result = Create(queue).Accept(snapshot);
+
+ result.Succeeded.ShouldBeFalse();
+ result.Message.ShouldNotBeNull().ShouldContain("net8.0 / net10.0");
+ queue.Accepted.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Ask_The_Owner_To_Accept_A_Queued_Snapshot()
+ {
+ // Applying in the owner rather than here is what keeps one writer per source file.
+ var queue = new FakeInlineQueueOwner { AcceptOutcome = InlineAcceptOutcome.Accepted };
+ var patch = InlineTestData.Patch("new snapshot");
+
+ var result = Create(queue).Accept(new(patch, isQueued: true));
+
+ result.Succeeded.ShouldBeTrue();
+ queue.Accepted.ShouldHaveSingleItem().ShouldBe(InlineKey.For(patch.SourceFile, patch.LineHint));
+ }
+
+ [Fact]
+ public void Should_Report_An_Accept_The_Owner_Refused()
+ {
+ var queue = new FakeInlineQueueOwner
+ {
+ AcceptOutcome = InlineAcceptOutcome.Failed,
+ AcceptMessage = "the file is open in an editor",
+ };
+
+ var result = Create(queue).Accept(new(InlineTestData.Patch("new snapshot"), isQueued: true));
+
+ result.Succeeded.ShouldBeFalse();
+ result.Message.ShouldBe("the file is open in an editor");
+ }
+
+ [Fact]
+ public void Should_Report_A_Queued_Accept_With_Nothing_To_Fall_Back_To()
+ {
+ // The owner went away between the listing and the accept, and the run staged nothing, so
+ // the click moved no file, changed no source and would otherwise report nothing at all.
+ var queue = new FakeInlineQueueOwner { AcceptOutcome = InlineAcceptOutcome.Unknown };
+
+ var result = Create(queue).Accept(new(InlineTestData.Patch("new snapshot"), isQueued: true));
+
+ result.Succeeded.ShouldBeFalse();
+ result.Message.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Should_Fall_Back_To_The_Staged_Patch_When_The_Owner_Went_Away()
+ {
+ // The other half of an Unknown: the owner went away between the listing and the accept, but
+ // the run that handed it the patch staged one too, so there is something left to apply.
+ using var source = new TemporarySource(
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot("old snapshot");
+ }
+ """);
+
+ var fileSystem = CreateFileSystem();
+ var patch = InlineTestData.Patch("new snapshot", line: 4, source: source.Path);
+ InlineTestData.Stage(fileSystem, patch);
+
+ var queue = new FakeInlineQueueOwner { AcceptOutcome = InlineAcceptOutcome.Unknown };
+ var snapshot = new InlineSnapshot(patch, isQueued: true, Staged(fileSystem, patch));
+
+ Create(queue, fileSystem).Accept(snapshot).Succeeded.ShouldBeTrue();
+
+ // Asked of the owner first, and applied here only because it did not answer for it.
+ queue.Accepted.ShouldHaveSingleItem();
+ source.Read().ShouldContain("""Snapshot("new snapshot")""");
+ StagedFiles(fileSystem).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Clear_The_Staged_Files_When_The_Owner_Accepts()
+ {
+ var fileSystem = CreateFileSystem();
+ var patch = InlineTestData.Patch("new snapshot");
+ InlineTestData.Stage(fileSystem, patch);
+
+ var queue = new FakeInlineQueueOwner { AcceptOutcome = InlineAcceptOutcome.Accepted };
+ var snapshot = new InlineSnapshot(patch, isQueued: true, Staged(fileSystem, patch));
+
+ Create(queue, fileSystem).Accept(snapshot).Succeeded.ShouldBeTrue();
+
+ StagedFiles(fileSystem).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Apply_A_Staged_Snapshot()
+ {
+ using var source = new TemporarySource(
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value).Snapshot("old snapshot");
+ }
+ """);
+
+ var fileSystem = CreateFileSystem();
+ var patch = InlineTestData.Patch("new snapshot", line: 4, source: source.Path);
+ InlineTestData.Stage(fileSystem, patch);
+
+ var snapshot = new InlineSnapshot(patch, isQueued: false, Staged(fileSystem, patch));
+
+ Create(fileSystem: fileSystem).Accept(snapshot).Succeeded.ShouldBeTrue();
+
+ source.Read().ShouldContain("""Snapshot("new snapshot")""");
+
+ // The staged files are what a scan reads, so with the snapshot in the source they are all
+ // that would still say it is pending.
+ StagedFiles(fileSystem).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Report_A_Staged_Snapshot_Whose_Call_Site_Is_Gone()
+ {
+ using var source = new TemporarySource(
+ """
+ public class SampleTests
+ {
+ public Task Sample() =>
+ Verify(value);
+ }
+ """);
+
+ var fileSystem = CreateFileSystem();
+ var patch = InlineTestData.Patch("new snapshot", line: 4, source: source.Path);
+ InlineTestData.Stage(fileSystem, patch);
+
+ var snapshot = new InlineSnapshot(patch, isQueued: false, Staged(fileSystem, patch));
+
+ var result = Create(fileSystem: fileSystem).Accept(snapshot);
+
+ result.Succeeded.ShouldBeFalse();
+ result.Message.ShouldNotBeNull().ShouldContain("Re-run the test");
+
+ // Nothing was applied, so the snapshot stays pending.
+ StagedFiles(fileSystem).ShouldNotBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Discard_A_Queued_Snapshot_On_Reject()
+ {
+ var queue = new FakeInlineQueueOwner { DiscardResult = true };
+ var patch = InlineTestData.Patch("new snapshot");
+
+ var result = Create(queue).Reject(new(patch, isQueued: true));
+
+ result.Succeeded.ShouldBeTrue();
+ queue.Discarded.ShouldHaveSingleItem().ShouldBe(InlineKey.For(patch.SourceFile, patch.LineHint));
+ }
+
+ [Fact]
+ public void Should_Treat_A_Discard_Of_Something_Already_Gone_As_Done()
+ {
+ // One error shape covers both "no entry for that key" and a refusal on a live one. An entry
+ // that has already gone is the outcome a reject wanted.
+ var queue = new FakeInlineQueueOwner
+ {
+ DiscardResult = false,
+ DiscardMessage = "unknown key",
+ StillPendingResult = false,
+ };
+
+ Create(queue).Reject(new(InlineTestData.Patch("new snapshot"), isQueued: true))
+ .Succeeded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Should_Report_A_Discard_The_Owner_Refused()
+ {
+ var queue = new FakeInlineQueueOwner
+ {
+ DiscardResult = false,
+ DiscardMessage = "still busy",
+ StillPendingResult = true,
+ };
+
+ var result = Create(queue).Reject(new(InlineTestData.Patch("new snapshot"), isQueued: true));
+
+ result.Succeeded.ShouldBeFalse();
+ result.Message.ShouldBe("still busy");
+ }
+
+ [Fact]
+ public void Should_Treat_A_Discard_The_Owner_Could_Not_Be_Asked_About_As_Done()
+ {
+ // The discard failed and the owner could not be asked whether the entry survived it: it
+ // went away, or answered with an error. Neither says the snapshot is still pending, so the
+ // staging is cleared and the reject stands rather than reporting a failure it cannot back.
+ var fileSystem = CreateFileSystem();
+ var patch = InlineTestData.Patch("new snapshot");
+ InlineTestData.Stage(fileSystem, patch);
+
+ var queue = new FakeInlineQueueOwner
+ {
+ DiscardResult = false,
+ DiscardMessage = "connection refused",
+ StillPendingResult = null,
+ };
+
+ var snapshot = new InlineSnapshot(patch, isQueued: true, Staged(fileSystem, patch));
+
+ Create(queue, fileSystem).Reject(snapshot).Succeeded.ShouldBeTrue();
+
+ StagedFiles(fileSystem).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Clear_The_Staged_Files_On_Reject()
+ {
+ var fileSystem = CreateFileSystem();
+ var patch = InlineTestData.Patch("new snapshot");
+ InlineTestData.Stage(fileSystem, patch);
+
+ var snapshot = new InlineSnapshot(patch, isQueued: false, Staged(fileSystem, patch));
+
+ Create(fileSystem: fileSystem).Reject(snapshot).Succeeded.ShouldBeTrue();
+
+ StagedFiles(fileSystem).ShouldBeEmpty();
+ }
+
+ private static FakeFileSystem CreateFileSystem() =>
+ new(new FakeEnvironment(PlatformFamily.Linux));
+
+ private static InlineSnapshotManager Create(
+ IInlineQueueOwner? queue = null,
+ FakeFileSystem? fileSystem = null) =>
+ new(fileSystem ?? CreateFileSystem(), queue ?? new FakeInlineQueueOwner());
+
+ // The staged trio as the finder builds it, for a test that starts from the manager instead.
+ private static IReadOnlyList Staged(FakeFileSystem fileSystem, InlinePatch patch)
+ {
+ var globber = new Globber(fileSystem, new FakeEnvironment(PlatformFamily.Linux));
+ var finder = new InlineSnapshotFinder(
+ globber,
+ new FakeEnvironment(PlatformFamily.Linux),
+ fileSystem,
+ new FakeInlineQueueOwner());
+
+ // Through the finder rather than by hand, so the manager is handed what it is handed in a
+ // real run: the patch as it was read back off disk, beside the files it was read from.
+ return finder
+ .Find(new DirectoryPath("/Working"))
+ .SelectMany(_ => _.Staged)
+ .ToList();
+ }
+
+ private static IReadOnlyList StagedFiles(FakeFileSystem fileSystem)
+ {
+ var globber = new Globber(fileSystem, new FakeEnvironment(PlatformFamily.Linux));
+ return globber
+ .Match("**/VerifyInline/*", new GlobberSettings { Root = new DirectoryPath("/Working") })
+ .OfType()
+ .Select(_ => _.FullPath)
+ .ToList();
+ }
+
+ // A real source file, since applying a patch is a rewrite of one and DiffEngine does its own IO.
+ private sealed class TemporarySource : IDisposable
+ {
+ private readonly string _directory;
+
+ public TemporarySource(string content)
+ {
+ _directory = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"verify-terminal-inline-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_directory);
+
+ Path = System.IO.Path.Combine(_directory, "SampleTests.cs");
+ File.WriteAllText(Path, content);
+ }
+
+ public string Path { get; }
+
+ public string Read() => File.ReadAllText(Path);
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ catch
+ {
+ // Best effort cleanup of the temp directory.
+ }
+ }
+ }
+}
diff --git a/src/Verify.Terminal.Tests/SnapshotFinderTests.cs b/src/Verify.Terminal.Tests/SnapshotFinderTests.cs
index 3a2cf22..c2c26d4 100644
--- a/src/Verify.Terminal.Tests/SnapshotFinderTests.cs
+++ b/src/Verify.Terminal.Tests/SnapshotFinderTests.cs
@@ -231,9 +231,21 @@ public void Should_Not_Cross_Match_Indexed_Files()
result.Verified.FullPath.ShouldBe("/Working/Foo.DotNet11_0#00.verified.txt");
}
+ [Fact]
+ public void Should_Ignore_The_Inline_Staging_Directory()
+ {
+ // Verify stages the received text of an inline snapshot under obj, named like any other
+ // received file. The snapshot it belongs to lives in a source file, so accepting it here
+ // would rename it to a verified file nothing reads and leave the real snapshot pending.
+ Find(
+ "/Working/obj/VerifyInline/N.Sample.a1b2c3d4.DotNet10_0.received.txt",
+ "/Working/obj/VerifyInline/N.Sample.a1b2c3d4.DotNet10_0.expected.txt")
+ .ShouldBeNull();
+ }
+
private static Snapshot? Find(params string[] files)
{
- var environment = new FakeEnvironment(Spectre.IO.PlatformFamily.Linux);
+ var environment = new FakeEnvironment(PlatformFamily.Linux);
var filesystem = new FakeFileSystem(environment);
var globber = new Globber(filesystem, environment);
diff --git a/src/Verify.Terminal.Tests/SnapshotRendererTests.cs b/src/Verify.Terminal.Tests/SnapshotRendererTests.cs
index 069b66d..73b4811 100644
--- a/src/Verify.Terminal.Tests/SnapshotRendererTests.cs
+++ b/src/Verify.Terminal.Tests/SnapshotRendererTests.cs
@@ -33,4 +33,65 @@ public Task Should_Render_Correctly(string scenario)
return Verifier.Verify(console.Output)
.UseTextForParameters(scenario);
}
+
+ [Fact]
+ [Expectation("RenderInline")]
+ public Task Should_Render_An_Inline_Snapshot()
+ {
+ // Given
+ var environment = new FakeEnvironment(PlatformFamily.Linux);
+ var filesystem = new FakeFileSystem(environment);
+ var console = new TestConsole();
+ var renderer = new SnapshotRenderer(console);
+ var differ = new SnapshotDiffer(filesystem, environment);
+
+ // An inline snapshot has no files to read: the literal in the source and the text the run
+ // produced both ride on the patch.
+ var diff = differ.Diff(
+ new InlineSnapshot(
+ InlineTestData.Patch(
+ """
+ line1
+ line2 changed
+ line3
+ """,
+ """
+ line1
+ line2
+ line3
+ """),
+ isQueued: true));
+
+ // When
+ console.Write(renderer.Render(diff, contextLines: 2));
+
+ // Then
+ return Verifier.Verify(console.Output);
+ }
+
+ [Fact]
+ [Expectation("RenderConflictedInline")]
+ public Task Should_Render_A_Conflicted_Inline_Snapshot()
+ {
+ // Given
+ var environment = new FakeEnvironment(PlatformFamily.Linux);
+ var filesystem = new FakeFileSystem(environment);
+ var console = new TestConsole();
+ var renderer = new SnapshotRenderer(console);
+ var differ = new SnapshotDiffer(filesystem, environment);
+
+ // Only the first of the frameworks' snapshots can be shown, so the header says the others
+ // are there.
+ var diff = differ.Diff(
+ new InlineSnapshot(
+ InlineTestData.Patch("from net10", "old snapshot"),
+ isQueued: true,
+ conflict: "net8.0 / net10.0"));
+
+ // When
+ console.Write(renderer.Render(diff, contextLines: 2));
+
+ // Then
+ return Verifier.Verify(console.Output);
+ }
}
\ No newline at end of file
diff --git a/src/Verify.Terminal.Tests/Utilities/FakeInlineQueueOwner.cs b/src/Verify.Terminal.Tests/Utilities/FakeInlineQueueOwner.cs
new file mode 100644
index 0000000..39bb28d
--- /dev/null
+++ b/src/Verify.Terminal.Tests/Utilities/FakeInlineQueueOwner.cs
@@ -0,0 +1,60 @@
+namespace Verify.Terminal.Tests;
+
+// Stands in for the process holding the inline queue. Every call on the real one is a loopback
+// exchange with another process, which a unit test has none of.
+internal sealed class FakeInlineQueueOwner : IInlineQueueOwner
+{
+ private readonly List _pending = [];
+
+ // False until something is queued, which is what a machine with no owner answers.
+ public bool HasOwner { get; set; }
+
+ public InlineAcceptOutcome AcceptOutcome { get; set; } = InlineAcceptOutcome.Accepted;
+
+ public string? AcceptMessage { get; set; }
+
+ public bool DiscardResult { get; set; } = true;
+
+ public string? DiscardMessage { get; set; }
+
+ public bool? StillPendingResult { get; set; }
+
+ public List Accepted { get; } = [];
+
+ public List Discarded { get; } = [];
+
+ public void Queue(InlinePatch patch)
+ {
+ HasOwner = true;
+ _pending.Add(new(patch));
+ }
+
+ public void Queue(params InlinePatch[] variants)
+ {
+ HasOwner = true;
+ _pending.Add(
+ new(variants.Select(_ => new InlineVariant(_, _.Framework == null ? [] : [_.Framework])).ToList()));
+ }
+
+ public bool TryList(out IReadOnlyList pending)
+ {
+ pending = _pending;
+ return HasOwner;
+ }
+
+ public InlineAcceptOutcome Accept(string key, out string? message)
+ {
+ Accepted.Add(key);
+ message = AcceptMessage;
+ return AcceptOutcome;
+ }
+
+ public bool Discard(string key, out string? message)
+ {
+ Discarded.Add(key);
+ message = DiscardMessage;
+ return DiscardResult;
+ }
+
+ public bool? StillPending(string key) => StillPendingResult;
+}
diff --git a/src/Verify.Terminal.Tests/Utilities/InlineTestData.cs b/src/Verify.Terminal.Tests/Utilities/InlineTestData.cs
new file mode 100644
index 0000000..5edccb4
--- /dev/null
+++ b/src/Verify.Terminal.Tests/Utilities/InlineTestData.cs
@@ -0,0 +1,49 @@
+namespace Verify.Terminal.Tests;
+
+// The shape of what a test run leaves behind for an inline snapshot, so the tests describe the
+// scenario rather than the file format.
+internal static class InlineTestData
+{
+ public const string SourceFile = "/Working/src/SampleTests.cs";
+ public const string StagingDirectory = "/Working/obj/VerifyInline";
+
+ public static InlinePatch Patch(
+ string content,
+ string? original = "old snapshot",
+ int line = 42,
+ string source = SourceFile,
+ InlinePatchMode mode = InlinePatchMode.Set) =>
+ new(source, line, null, content, mode)
+ {
+ TestName = "SampleTests.Sample",
+ MemberName = "Sample",
+ OriginalValue = original,
+ };
+
+ // Verify names the staged files `{type}.{method}.{hash}.{runtime}`, and writes the two texts
+ // beside the patch.
+ public static void Stage(
+ FakeFileSystem fileSystem,
+ InlinePatch patch,
+ string runtime = "DotNet10_0",
+ bool withTexts = true)
+ {
+ var name = $"SampleTests.Sample.a1b2c3d4.{runtime}";
+
+ fileSystem
+ .CreateFile($"{StagingDirectory}/{name}.inlinepatch")
+ .SetTextContent(InlinePatchFile.Build(patch));
+
+ if (!withTexts)
+ {
+ return;
+ }
+
+ fileSystem
+ .CreateFile($"{StagingDirectory}/{name}.received.txt")
+ .SetTextContent(patch.NewContent);
+ fileSystem
+ .CreateFile($"{StagingDirectory}/{name}.expected.txt")
+ .SetTextContent(patch.OriginalValue ?? string.Empty);
+ }
+}
diff --git a/src/Verify.Terminal.Tests/Verify.Terminal.Tests.csproj b/src/Verify.Terminal.Tests/Verify.Terminal.Tests.csproj
index 88dae0a..04a9a13 100644
--- a/src/Verify.Terminal.Tests/Verify.Terminal.Tests.csproj
+++ b/src/Verify.Terminal.Tests/Verify.Terminal.Tests.csproj
@@ -25,4 +25,8 @@
+
+
+
+
diff --git a/src/Verify.Terminal/Commands/Modify/AcceptCommand.cs b/src/Verify.Terminal/Commands/Modify/AcceptCommand.cs
index 65d9ce3..15db3ae 100644
--- a/src/Verify.Terminal/Commands/Modify/AcceptCommand.cs
+++ b/src/Verify.Terminal/Commands/Modify/AcceptCommand.cs
@@ -6,9 +6,9 @@ public sealed class AcceptCommand : ModifyCommand
public override SnapshotAction Action { get; } = SnapshotAction.Accept;
public AcceptCommand(
- SnapshotFinder snapshotFinder,
+ SnapshotLocator snapshotLocator,
SnapshotManager snapshotManager)
- : base(snapshotFinder, snapshotManager)
+ : base(snapshotLocator, snapshotManager)
{
}
}
\ No newline at end of file
diff --git a/src/Verify.Terminal/Commands/Modify/RejectCommand.cs b/src/Verify.Terminal/Commands/Modify/RejectCommand.cs
index 285074d..29d89c5 100644
--- a/src/Verify.Terminal/Commands/Modify/RejectCommand.cs
+++ b/src/Verify.Terminal/Commands/Modify/RejectCommand.cs
@@ -6,9 +6,9 @@ public sealed class RejectCommand : ModifyCommand
public override SnapshotAction Action { get; } = SnapshotAction.Reject;
public RejectCommand(
- SnapshotFinder snapshotFinder,
+ SnapshotLocator snapshotLocator,
SnapshotManager snapshotManager)
- : base(snapshotFinder, snapshotManager)
+ : base(snapshotLocator, snapshotManager)
{
}
}
\ No newline at end of file
diff --git a/src/Verify.Terminal/Commands/ModifyCommand.cs b/src/Verify.Terminal/Commands/ModifyCommand.cs
index 2688c78..51ab90f 100644
--- a/src/Verify.Terminal/Commands/ModifyCommand.cs
+++ b/src/Verify.Terminal/Commands/ModifyCommand.cs
@@ -2,7 +2,7 @@ namespace Verify.Terminal.Commands;
public abstract class ModifyCommand : Command
{
- private readonly SnapshotFinder _snapshotFinder;
+ private readonly SnapshotLocator _snapshotLocator;
private readonly SnapshotManager _snapshotManager;
public abstract string Verb { get; }
@@ -20,9 +20,9 @@ public sealed class Settings : CommandSettings
public bool NoPrompt { get; set; }
}
- protected ModifyCommand(SnapshotFinder snapshotFinder, SnapshotManager snapshotManager)
+ protected ModifyCommand(SnapshotLocator snapshotLocator, SnapshotManager snapshotManager)
{
- _snapshotFinder = snapshotFinder.NotNull();
+ _snapshotLocator = snapshotLocator.NotNull();
_snapshotManager = snapshotManager.NotNull();
}
@@ -32,7 +32,7 @@ protected sealed override int Execute(
CancellationToken cancellationToken)
{
// Get all snapshots and show a summary
- var snapshots = _snapshotFinder.Find(settings.Root);
+ var snapshots = _snapshotLocator.Find(settings.Root);
if (snapshots.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No snapshots found.[/]");
@@ -46,18 +46,28 @@ protected sealed override int Execute(
return 1;
}
- // Process snapshots
+ // Process snapshots. One that refuses does not stop the rest: an inline snapshot whose
+ // frameworks disagreed is skipped rather than picked between, and holding up every other
+ // snapshot behind it would mean one unresolvable snapshot blocking the whole command.
+ var failed = 0;
foreach (var snapshot in snapshots)
{
- if (!_snapshotManager.Process(snapshot, Action))
+ var result = _snapshotManager.Process(snapshot, Action);
+ if (!result.Succeeded)
{
- AnsiConsole.MarkupLineInterpolated(
- $"[red]Error:[/] An error occured while processing snapshot: {snapshot.Received}");
- return 2;
+ failed++;
+ AnsiConsole.Console.ShowSnapshotFailure(snapshot, Action, result);
}
}
- return 0;
+ if (failed == 0)
+ {
+ return 0;
+ }
+
+ AnsiConsole.MarkupLineInterpolated(
+ $"[red]{failed} of {snapshots.Count} snapshot(s) could not be processed.[/]");
+ return 2;
}
private static bool Proceed(Settings settings, string question)
@@ -69,4 +79,4 @@ private static bool Proceed(Settings settings, string question)
return AnsiConsole.Console.AskYesNo(question);
}
-}
\ No newline at end of file
+}
diff --git a/src/Verify.Terminal/Commands/ReviewCommand.cs b/src/Verify.Terminal/Commands/ReviewCommand.cs
index 3680690..6c7170b 100644
--- a/src/Verify.Terminal/Commands/ReviewCommand.cs
+++ b/src/Verify.Terminal/Commands/ReviewCommand.cs
@@ -2,18 +2,18 @@ namespace Verify.Terminal.Commands;
public sealed class ReviewCommand : Command
{
- private readonly SnapshotFinder _snapshotFinder;
+ private readonly SnapshotLocator _snapshotLocator;
private readonly SnapshotDiffer _snapshotDiffer;
private readonly SnapshotManager _snapshotManager;
private readonly SnapshotRenderer _snapshotRenderer;
public ReviewCommand(
- SnapshotFinder snapshotFinder,
+ SnapshotLocator snapshotLocator,
SnapshotDiffer snapshotDiffer,
SnapshotManager snapshotManager,
SnapshotRenderer snapshotRenderer)
{
- _snapshotFinder = snapshotFinder.NotNull();
+ _snapshotLocator = snapshotLocator.NotNull();
_snapshotDiffer = snapshotDiffer.NotNull();
_snapshotManager = snapshotManager.NotNull();
_snapshotRenderer = snapshotRenderer.NotNull();
@@ -37,7 +37,7 @@ protected override int Execute(
CancellationToken cancellationToken)
{
// Get all snapshots and show a summary
- var snapshots = _snapshotFinder.Find(settings.Root);
+ var snapshots = _snapshotLocator.Find(settings.Root);
if (snapshots.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No snapshots to review.[/]");
@@ -57,16 +57,19 @@ protected override int Execute(
AnsiConsole.MarkupLine($"[yellow b]Reviewing[/] [[{index + 1}/{snapshots.Count}]]");
AnsiConsole.Write(_snapshotRenderer.Render(diff, Math.Max(0, settings.ContextLines)));
- switch (ShowPrompt())
+ var action = ShowPrompt();
+ if (action == SnapshotAction.Skip)
{
- case SnapshotAction.Accept:
- _snapshotManager.Accept(snapshot);
- break;
- case SnapshotAction.Reject:
- _snapshotManager.Reject(snapshot);
- break;
- case SnapshotAction.Skip:
- continue;
+ continue;
+ }
+
+ // Reported rather than returned on, since the rest of the review is still worth doing.
+ // An inline snapshot refuses for reasons that are about it alone: the source moved since
+ // the test ran, or its frameworks disagreed about the content.
+ var result = _snapshotManager.Process(snapshot, action);
+ if (!result.Succeeded)
+ {
+ AnsiConsole.Console.ShowSnapshotFailure(snapshot, action, result);
}
if (!last)
@@ -116,4 +119,4 @@ private static SnapshotAction ShowPrompt()
AnsiConsole.Cursor.Show();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Verify.Terminal/Extensions/ConsoleExtensions.cs b/src/Verify.Terminal/Extensions/ConsoleExtensions.cs
index 6fd75f3..6c46182 100644
--- a/src/Verify.Terminal/Extensions/ConsoleExtensions.cs
+++ b/src/Verify.Terminal/Extensions/ConsoleExtensions.cs
@@ -2,7 +2,7 @@ namespace Verify.Terminal;
public static class ConsoleExtensions
{
- public static void ShowSnapshotSummary(this IAnsiConsole console, IEnumerable snapshots)
+ public static void ShowSnapshotSummary(this IAnsiConsole console, IEnumerable snapshots)
{
var table = new Table();
@@ -10,13 +10,33 @@ public static void ShowSnapshotSummary(this IAnsiConsole console, IEnumerable
+ /// Reports a snapshot that could not be accepted or rejected, and why when there is a why. The
+ /// reason is the interesting half for an inline snapshot, where a refusal usually says what to
+ /// do about it.
+ ///
+ public static void ShowSnapshotFailure(
+ this IAnsiConsole console,
+ ISnapshot snapshot,
+ SnapshotAction action,
+ SnapshotResult result)
+ {
+ var verb = action == SnapshotAction.Accept ? "accept" : "reject";
+ console.MarkupLineInterpolated($"[red]Error:[/] Could not {verb} snapshot: {snapshot.Name}");
+
+ if (result.Message != null)
+ {
+ console.MarkupLineInterpolated($"[grey]{result.Message}[/]");
+ }
+ }
+
public static bool AskYesNo(this IAnsiConsole console, string question)
{
return console.Prompt(new SelectionPrompt()
diff --git a/src/Verify.Terminal/GlobalUsings.cs b/src/Verify.Terminal/GlobalUsings.cs
index 01b47b5..1c3e43e 100644
--- a/src/Verify.Terminal/GlobalUsings.cs
+++ b/src/Verify.Terminal/GlobalUsings.cs
@@ -1,6 +1,7 @@
global using System.ComponentModel;
global using System.Globalization;
global using System.Runtime.CompilerServices;
+global using DiffEngine;
global using DiffPlex.DiffBuilder;
global using DiffPlex.DiffBuilder.Model;
global using Spectre.IO;
diff --git a/src/Verify.Terminal/ISnapshot.cs b/src/Verify.Terminal/ISnapshot.cs
new file mode 100644
index 0000000..7cad1ca
--- /dev/null
+++ b/src/Verify.Terminal/ISnapshot.cs
@@ -0,0 +1,24 @@
+namespace Verify.Terminal;
+
+///
+/// A pending snapshot, whether its expected text lives in a `.verified.` file or in a string
+/// literal in the test source.
+///
+public interface ISnapshot
+{
+ ///
+ /// Identifies the snapshot in summaries and in errors: a path for a file snapshot, and a
+ /// `path:line` call site for an inline one.
+ ///
+ string Name { get; }
+
+ ///
+ /// The lines shown above the diff, never empty. Each is a path, plus an optional note about it.
+ ///
+ IReadOnlyList Headers { get; }
+}
+
+///
+/// One line of a diff header: what is being shown, and a caveat about it when there is one.
+///
+public sealed record SnapshotHeader(string Path, string? Note = null);
diff --git a/src/Verify.Terminal/InlineQueueOwner.cs b/src/Verify.Terminal/InlineQueueOwner.cs
new file mode 100644
index 0000000..b48c388
--- /dev/null
+++ b/src/Verify.Terminal/InlineQueueOwner.cs
@@ -0,0 +1,63 @@
+namespace Verify.Terminal;
+
+///
+/// The process holding the pending inline snapshots: DiffEngineTray when one is running, and
+/// otherwise the DiffEngineViewer a test run launched.
+///
+///
+/// An interface so the queue can be stood in for. Every call behind it is a short loopback exchange
+/// with another process, which a test has none of, and which is refused outright on a machine where
+/// nothing is pending.
+///
+public interface IInlineQueueOwner
+{
+ ///
+ /// Every pending inline snapshot the owner holds. False when no owner answered, which is not
+ /// the same as an empty queue: only the first means there may be staged files to fall back to.
+ ///
+ bool TryList(out IReadOnlyList pending);
+
+ ///
+ /// Asks the owner to apply the patch for a call site and drop it. Applying in the owner rather
+ /// than here is what keeps one writer per source file.
+ ///
+ InlineAcceptOutcome Accept(string key, out string? message);
+
+ ///
+ /// Drops a pending snapshot without applying it.
+ ///
+ bool Discard(string key, out string? message);
+
+ ///
+ /// Whether the owner still holds the call site, or null when it could not be asked.
+ ///
+ bool? StillPending(string key);
+}
+
+///
+/// over the real queue.
+///
+public sealed class InlineQueueOwner : IInlineQueueOwner
+{
+ public bool TryList(out IReadOnlyList pending) =>
+ InlineQueueClient.TryList(out pending);
+
+ public InlineAcceptOutcome Accept(string key, out string? message) =>
+ InlineQueueClient.Accept(key, out message);
+
+ public bool Discard(string key, out string? message) =>
+ InlineQueueClient.Discard(key, out message);
+
+ public bool? StillPending(string key)
+ {
+ // Over the listing that carries no patches, since the answer is a yes or a no rather than
+ // anything to render. A listing that fails is not a no: the owner went away, or answered
+ // with an error, and neither says the snapshot is gone.
+ if (!InlineQueueClient.TryListKeys(out var keys))
+ {
+ return null;
+ }
+
+ return keys.Contains(key);
+ }
+}
diff --git a/src/Verify.Terminal/InlineSnapshot.cs b/src/Verify.Terminal/InlineSnapshot.cs
new file mode 100644
index 0000000..786c880
--- /dev/null
+++ b/src/Verify.Terminal/InlineSnapshot.cs
@@ -0,0 +1,106 @@
+namespace Verify.Terminal;
+
+///
+/// A pending inline snapshot: the expected text lives in a string literal in the test source, so
+/// accepting rewrites that source instead of moving a file.
+///
+///
+/// A test run hands its patch to whichever process owns the inline queue, which is DiffEngineTray
+/// when one is running and otherwise the DiffEngineViewer the run launched. Only when nothing
+/// answers does it stage the patch, and the two texts, under `obj/VerifyInline/`. So a snapshot
+/// here came from one of those two places, and occasionally from both, when a run staged its patch
+/// and an owner arrived afterwards.
+///
+public sealed class InlineSnapshot : ISnapshot
+{
+ public InlineSnapshot(
+ InlinePatch patch,
+ bool isQueued,
+ IReadOnlyList? staged = null,
+ string? conflict = null)
+ {
+ Patch = patch.NotNull();
+ IsQueued = isQueued;
+ Staged = staged ?? [];
+ Conflict = conflict;
+ }
+
+ ///
+ /// The edit the test run produced. Also carries the anchors that say which call it came from,
+ /// so a source file that has shifted since still patches.
+ ///
+ public InlinePatch Patch { get; }
+
+ ///
+ /// Held by a queue owner, so accepting is asked of it rather than done here. That is what keeps
+ /// one writer per source file, and leaves every surface agreeing about what is still pending.
+ ///
+ public bool IsQueued { get; }
+
+ ///
+ /// What a run staged when nothing owned a queue, empty when one did. More than one when a
+ /// multi targeted run staged a patch per framework.
+ ///
+ public IReadOnlyList Staged { get; }
+
+ ///
+ /// Set when this call site has more than one content, which a multi targeted run produces when
+ /// its frameworks disagree. Names them. Only one of them is rendered, so accepting would be
+ /// picking between them silently, and is refused instead.
+ ///
+ public string? Conflict { get; }
+
+ ///
+ /// How the queue addresses this call site. A re-run of the same test produces the same key.
+ ///
+ public string Key => InlineKey.For(Patch.SourceFile, Patch.LineHint);
+
+ public string SourceFile => Patch.SourceFile;
+
+ ///
+ /// 1 based line of the call. A hint the patcher starts from rather than an address: the literal
+ /// itself is found by content search.
+ ///
+ public int Line => Patch.LineHint;
+
+ ///
+ /// The snapshot the source holds as it stands. Empty for one that has no literal yet, which
+ /// compares as an empty verified file does.
+ ///
+ public string Expected => Patch.OriginalValue ?? string.Empty;
+
+ ///
+ /// The snapshot the test run produced.
+ ///
+ public string Received => Patch.NewContent;
+
+ public string Name => $"{SourceFile}:{Line}";
+
+ public IReadOnlyList Headers =>
+ [new($"{new FilePath(SourceFile).GetFilename().FullPath}:{Line}", Note)];
+
+ // Said on every inline snapshot, since the header is otherwise a source file where a reviewer
+ // is used to reading a `.received.` file, and accepting one edits that source.
+ private string Note =>
+ Conflict == null
+ ? "(inline)"
+ : $"(inline, conflicting: {Conflict})";
+}
+
+///
+/// The files a test run left behind for one inline snapshot when nothing owned a queue: the patch,
+/// and the two texts it was staged beside.
+///
+/// The edit, as read back from .
+/// The staged patch file.
+/// The staged received text, or null when it is not there.
+/// The staged expected text, or null when it is not there.
+///
+/// The framework that staged this, read off the file name. Null when the name does not carry one.
+///
+public sealed record StagedInline(
+ InlinePatch Patch,
+ FilePath PatchPath,
+ FilePath? ReceivedPath,
+ FilePath? ExpectedPath,
+ string? Origin);
diff --git a/src/Verify.Terminal/InlineSnapshotFinder.cs b/src/Verify.Terminal/InlineSnapshotFinder.cs
new file mode 100644
index 0000000..370dc11
--- /dev/null
+++ b/src/Verify.Terminal/InlineSnapshotFinder.cs
@@ -0,0 +1,216 @@
+namespace Verify.Terminal;
+
+///
+/// Finds the inline snapshots a test run left pending.
+///
+///
+/// Two sources, because a run hands its patch to whichever process owns the inline queue and only
+/// stages files under `obj/VerifyInline/` when nothing answered. Both are read: a machine running
+/// DiffEngineTray has everything in the queue and nothing on disk, and a machine without one has
+/// the opposite, so a tool that reads only one of them reports nothing pending for half its users.
+///
+public sealed class InlineSnapshotFinder
+{
+ ///
+ /// The directory Verify stages an inline snapshot under, inside the intermediate (obj)
+ /// directory of the test project. Only a convention, so everything read out of it is checked
+ /// rather than assumed.
+ ///
+ public const string StagingDirectoryName = "VerifyInline";
+
+ private const string PatchPattern = $"**/{StagingDirectoryName}/*.inlinepatch";
+
+ // Windows and macOS paths are case insensitive, and a source file reaches the queue from
+ // different senders with different casing.
+ private static readonly StringComparison _pathComparison =
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ private readonly IGlobber _globber;
+ private readonly IEnvironment _environment;
+ private readonly IFileSystem _fileSystem;
+ private readonly IInlineQueueOwner _queue;
+
+ public InlineSnapshotFinder(
+ IGlobber globber,
+ IEnvironment environment,
+ IFileSystem fileSystem,
+ IInlineQueueOwner queue)
+ {
+ _globber = globber.NotNull();
+ _environment = environment.NotNull();
+ _fileSystem = fileSystem.NotNull();
+ _queue = queue.NotNull();
+ }
+
+ public IReadOnlyList Find(DirectoryPath? root = null)
+ {
+ root ??= _environment.WorkingDirectory;
+ root = root.MakeAbsolute(_environment);
+
+ var staged = Staged(root);
+ var result = new List();
+
+ // The owner holds the live state, so what it has takes precedence over anything left on
+ // disk. Staged files for the same call site are carried across rather than dropped: they
+ // are still there to clean up, and they are the fallback if the owner goes away before the
+ // accept.
+ foreach (var pending in Queued(root))
+ {
+ staged.Remove(pending.Key, out var files);
+ result.Add(
+ new(
+ pending.Patch,
+ isQueued: true,
+ files,
+ pending.Conflicted ? pending.OriginsLabel : null));
+ }
+
+ result.AddRange(staged.Values.Select(FromStaged));
+
+ return result;
+ }
+
+ private IEnumerable Queued(DirectoryPath root)
+ {
+ // A refused connection means nothing owns a queue, which is the ordinary state of a machine
+ // with nothing pending, and also the state a run that staged its patches left behind.
+ if (!_queue.TryList(out var pending))
+ {
+ return [];
+ }
+
+ // The queue is machine wide: one owner holds the pending snapshots of every solution on the
+ // machine. Only the ones under the directory being scanned are this run's business.
+ return pending
+ .Where(_ => IsReviewable(_.Patch))
+ .Where(_ => IsUnder(root, _.Patch.SourceFile))
+ .OrderBy(_ => _.Patch.SourceFile, StringComparer.Ordinal)
+ .ThenBy(_ => _.Patch.LineHint);
+ }
+
+ private Dictionary> Staged(DirectoryPath root)
+ {
+ var result = new Dictionary>(StringComparer.Ordinal);
+
+ // Ordered, so the patch a conflicted call site renders is the same one on every run.
+ var paths = _globber
+ .Match(
+ PatchPattern,
+ new()
+ {
+ Root = root
+ })
+ .OfType()
+ .OrderBy(_ => _.FullPath, StringComparer.Ordinal);
+
+ foreach (var path in paths)
+ {
+ if (!TryReadPatch(path, out var patch) ||
+ !IsReviewable(patch))
+ {
+ continue;
+ }
+
+ var key = InlineKey.For(patch.SourceFile, patch.LineHint);
+ if (!result.TryGetValue(key, out var group))
+ {
+ result[key] = group = [];
+ }
+
+ group.Add(
+ new(
+ patch,
+ path,
+ Sibling(path, "received.txt"),
+ Sibling(path, "expected.txt"),
+ Origin(path)));
+ }
+
+ return result;
+ }
+
+ private static InlineSnapshot FromStaged(List staged)
+ {
+ // A multi targeted run stages one patch per framework. Identical content is a single
+ // snapshot; content that differs is the frameworks disagreeing, and only one of them can be
+ // rendered. Compared through the patch's own definition of sameness, which ignores which
+ // framework produced it.
+ var conflicted = staged.Any(_ => !_.Patch.Matches(staged[0].Patch));
+
+ var conflict = conflicted
+ ? string.Join(" / ", staged.Select(_ => _.Origin ?? "unknown").Distinct(StringComparer.Ordinal))
+ : null;
+
+ return new(staged[0].Patch, isQueued: false, staged, conflict);
+ }
+
+ // A Remove is applied by whoever produced it and is never reviewed, so one reaching here is not
+ // a pending snapshot. Checked rather than assumed, since a patch is read off disk.
+ private static bool IsReviewable(InlinePatch patch) =>
+ patch.Mode != InlinePatchMode.Remove;
+
+ private bool IsUnder(DirectoryPath root, string path)
+ {
+ var prefix = root.FullPath.TrimEnd('/') + "/";
+ return new FilePath(path)
+ .MakeAbsolute(_environment)
+ .FullPath
+ .StartsWith(prefix, _pathComparison);
+ }
+
+ // Verify names the staged files `{type}.{method}.{hash}.{runtime}`, so the last segment says
+ // which framework produced them. Best effort: it only ever labels a conflict.
+ private static string? Origin(FilePath path)
+ {
+ var stem = path.GetFilenameWithoutExtension().FullPath;
+ var index = stem.LastIndexOf('.');
+ if (index < 0 ||
+ index == stem.Length - 1)
+ {
+ return null;
+ }
+
+ return stem[(index + 1)..];
+ }
+
+ // The two texts a run stages beside a patch. Not needed to render the snapshot, since the patch
+ // carries both, but they are part of what says it is still pending, so part of the cleanup.
+ private FilePath? Sibling(FilePath patch, string extension)
+ {
+ var path = new FilePath(
+ $"{patch.GetDirectory().FullPath}/{patch.GetFilenameWithoutExtension().FullPath}.{extension}");
+
+ return _fileSystem.File.Exists(path) ? path : null;
+ }
+
+ private bool TryReadPatch(FilePath path, [NotNullWhen(true)] out InlinePatch? patch)
+ {
+ patch = null;
+
+ // Read through the file system abstraction rather than DiffEngine's own TryRead, so this
+ // scan sees what the rest of the tool sees.
+ if (!_fileSystem.File.Exists(path))
+ {
+ return false;
+ }
+
+ string text;
+ try
+ {
+ using var stream = _fileSystem.File.OpenRead(path);
+ using var reader = new StreamReader(stream);
+ text = reader.ReadToEnd();
+ }
+ catch (Exception exception)
+ when (exception is IOException or UnauthorizedAccessException)
+ {
+ // A file that cannot be read is one snapshot that cannot be reviewed, rather than a
+ // scan that fails.
+ return false;
+ }
+
+ return InlinePatchFile.TryParse(text, out patch);
+ }
+}
diff --git a/src/Verify.Terminal/InlineSnapshotManager.cs b/src/Verify.Terminal/InlineSnapshotManager.cs
new file mode 100644
index 0000000..58011c8
--- /dev/null
+++ b/src/Verify.Terminal/InlineSnapshotManager.cs
@@ -0,0 +1,157 @@
+namespace Verify.Terminal;
+
+///
+/// Accepts and rejects inline snapshots.
+///
+///
+/// A queued snapshot is accepted by asking its owner, which is what keeps one writer per source
+/// file and leaves every surface agreeing about what is still pending. Only a snapshot no owner
+/// holds is applied here, from the patch the test run staged.
+///
+public sealed class InlineSnapshotManager
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly IInlineQueueOwner _queue;
+
+ public InlineSnapshotManager(IFileSystem fileSystem, IInlineQueueOwner queue)
+ {
+ _fileSystem = fileSystem.NotNull();
+ _queue = queue.NotNull();
+ }
+
+ ///
+ /// Puts the snapshot in the source file. Succeeds when it is there afterwards, whether this
+ /// call put it there or an earlier one did.
+ ///
+ public SnapshotResult Accept(InlineSnapshot snapshot)
+ {
+ snapshot.NotNull();
+
+ // Only one of the frameworks' snapshots is rendered, so accepting would be picking between
+ // them without having shown them. Refused here as it is in the tray, and for the same
+ // reason.
+ if (snapshot.Conflict != null)
+ {
+ return SnapshotResult.Failure(
+ $"Conflicting snapshots ({snapshot.Conflict}). Resolve them in DiffEngineViewer, or re-run the tests so the frameworks agree.");
+ }
+
+ if (snapshot.IsQueued)
+ {
+ var outcome = _queue.Accept(snapshot.Key, out var message);
+ if (outcome == InlineAcceptOutcome.Accepted)
+ {
+ // The owner applied it and dropped the entry. A run whose patch an owner took
+ // stages nothing, so there is usually nothing left to clean up.
+ return DeleteStaged(snapshot);
+ }
+
+ if (outcome == InlineAcceptOutcome.Failed)
+ {
+ return SnapshotResult.Failure(message);
+ }
+
+ // Unknown: the owner went away between the listing and the accept, or something else
+ // took the entry first. Whatever the run staged, if anything, is all that is left.
+ if (snapshot.Staged.Count == 0)
+ {
+ return SnapshotResult.Failure(
+ "The queue owner did not apply it. It may have been accepted elsewhere, or the owner may have exited, and the test run staged no patch to fall back on. Re-run the test if the snapshot is still pending.");
+ }
+ }
+
+ return Apply(snapshot);
+ }
+
+ ///
+ /// Drops the snapshot, leaving the source file as it is.
+ ///
+ public SnapshotResult Reject(InlineSnapshot snapshot)
+ {
+ snapshot.NotNull();
+
+ if (snapshot.IsQueued &&
+ !_queue.Discard(snapshot.Key, out var message) &&
+ _queue.StillPending(snapshot.Key) == true)
+ {
+ // One error shape covers both "no entry for that key" and a refusal on a live one, so
+ // which it was is asked rather than read out of the text. An entry that has already
+ // gone is the outcome a reject wanted.
+ return SnapshotResult.Failure(message);
+ }
+
+ return DeleteStaged(snapshot);
+ }
+
+ private SnapshotResult Apply(InlineSnapshot snapshot)
+ {
+ var staged = snapshot.Staged.FirstOrDefault();
+ if (staged == null)
+ {
+ return SnapshotResult.Failure("The test run staged no patch to apply.");
+ }
+
+ // InlineApplier owns all locking, in process and cross process, so applying beside a tray or
+ // a viewer doing the same is safe. No locking is added here.
+ var result = InlineApplier.Apply(staged.Patch);
+ switch (result.Status)
+ {
+ case InlineApplyStatus.Applied:
+ case InlineApplyStatus.AlreadyApplied:
+ // The run that staged these files may still have queued the patch with an owner
+ // that arrived afterwards, and that queue outlives the run. Without this a tray
+ // keeps offering a snapshot that is already in the source.
+ //
+ // The applied verb, not SettleInline: that one labels the settle with the running
+ // process's framework, which here is this tool's rather than the test project's, so
+ // the owner finds no variant to strip and does nothing - and answers no differently
+ // than if it had, so the miss says nothing. This one carries no framework, which is
+ // the true statement anyway: the literal every variant was anchored to has gone.
+ DiffRunner.SettleAppliedInline(staged.Patch);
+ return DeleteStaged(snapshot);
+
+ case InlineApplyStatus.NotFound:
+ return SnapshotResult.Failure(
+ "The call site could not be found, so the source has changed since the test ran. Re-run the test and accept again.");
+
+ default:
+ return SnapshotResult.Failure(result.Message);
+ }
+ }
+
+ ///
+ /// Clears the files a run staged. They are what a scan reads, so with them gone the snapshot
+ /// stops being pending, and while they are there it does not.
+ ///
+ private SnapshotResult DeleteStaged(InlineSnapshot snapshot)
+ {
+ foreach (var staged in snapshot.Staged)
+ {
+ foreach (var path in new[] { staged.PatchPath, staged.ReceivedPath, staged.ExpectedPath })
+ {
+ if (path == null ||
+ !_fileSystem.File.Exists(path))
+ {
+ continue;
+ }
+
+ try
+ {
+ _fileSystem.File.Delete(path);
+ }
+ catch (Exception exception)
+ when (exception is IOException or UnauthorizedAccessException)
+ {
+ return SnapshotResult.Failure($"The staged file could not be deleted: {path.FullPath}");
+ }
+
+ if (_fileSystem.File.Exists(path))
+ {
+ return SnapshotResult.Failure($"The staged file could not be deleted: {path.FullPath}");
+ }
+ }
+ }
+
+ return SnapshotResult.Success;
+ }
+}
diff --git a/src/Verify.Terminal/Program.cs b/src/Verify.Terminal/Program.cs
index 22008e2..defd58e 100644
--- a/src/Verify.Terminal/Program.cs
+++ b/src/Verify.Terminal/Program.cs
@@ -28,7 +28,12 @@ private static TypeRegistrar BuildContainer()
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Verify.Terminal/Snapshot.cs b/src/Verify.Terminal/Snapshot.cs
index da32a37..81a2fa8 100644
--- a/src/Verify.Terminal/Snapshot.cs
+++ b/src/Verify.Terminal/Snapshot.cs
@@ -1,11 +1,30 @@
namespace Verify.Terminal;
-public sealed class Snapshot
+public sealed class Snapshot : ISnapshot
{
public FilePath Received { get; }
public FilePath Verified { get; }
public bool IsRerouted { get; }
+ public string Name => Received.FullPath;
+
+ public IReadOnlyList Headers
+ {
+ get
+ {
+ var received = new SnapshotHeader(Received.GetFilename().FullPath);
+
+ // The verified name is only worth a line of its own when it is not the one the received
+ // name reads as, which is exactly when the snapshot was rerouted.
+ if (!IsRerouted)
+ {
+ return [received];
+ }
+
+ return [received, new(Verified.GetFilename().FullPath, "(rerouted)")];
+ }
+ }
+
public Snapshot(FilePath received)
{
Received = received.NotNull();
diff --git a/src/Verify.Terminal/SnapshotDiff.cs b/src/Verify.Terminal/SnapshotDiff.cs
index 9f39098..fa99d2e 100644
--- a/src/Verify.Terminal/SnapshotDiff.cs
+++ b/src/Verify.Terminal/SnapshotDiff.cs
@@ -2,11 +2,11 @@ namespace Verify.Terminal;
public sealed class SnapshotDiff
{
- public Snapshot Snapshot { get; }
+ public ISnapshot Snapshot { get; }
public List Old { get; }
public List New { get; }
- public SnapshotDiff(Snapshot snapshot, List old, List @new)
+ public SnapshotDiff(ISnapshot snapshot, List old, List @new)
{
Snapshot = snapshot.NotNull();
Old = old.NotNull();
diff --git a/src/Verify.Terminal/SnapshotDiffer.cs b/src/Verify.Terminal/SnapshotDiffer.cs
index aef4bb3..6e693ac 100644
--- a/src/Verify.Terminal/SnapshotDiffer.cs
+++ b/src/Verify.Terminal/SnapshotDiffer.cs
@@ -11,16 +11,27 @@ public SnapshotDiffer(IFileSystem fileSystem, IEnvironment environment)
_environment = environment.NotNull();
}
- public SnapshotDiff Diff(Snapshot snapshot)
+ public SnapshotDiff Diff(ISnapshot snapshot)
{
- var oldText = ReadText(snapshot.Verified) ?? string.Empty;
- var newText = ReadText(snapshot.Received) ?? string.Empty;
+ var (oldText, newText) = Text(snapshot);
var diff = SideBySideDiffBuilder.Instance.BuildDiffModel(oldText, newText, false);
return new SnapshotDiff(snapshot, diff.OldText.Lines, diff.NewText.Lines);
}
+ private (string Old, string New) Text(ISnapshot snapshot)
+ {
+ return snapshot.NotNull() switch
+ {
+ Snapshot file => (ReadText(file.Verified) ?? string.Empty, ReadText(file.Received) ?? string.Empty),
+ // An inline snapshot is held in memory: its expected text is the literal in the source,
+ // which the patch already carries, so there is nothing to read to compare the two.
+ InlineSnapshot inline => (inline.Expected, inline.Received),
+ _ => throw new InvalidOperationException($"Unknown snapshot type: {snapshot.GetType().Name}"),
+ };
+ }
+
private string? ReadText(FilePath path)
{
path = path.MakeAbsolute(_environment);
diff --git a/src/Verify.Terminal/SnapshotFinder.cs b/src/Verify.Terminal/SnapshotFinder.cs
index 532471c..d23b923 100644
--- a/src/Verify.Terminal/SnapshotFinder.cs
+++ b/src/Verify.Terminal/SnapshotFinder.cs
@@ -52,8 +52,17 @@ private IEnumerable Match(DirectoryPath root, string pattern, string
_globber
.Match(pattern, new GlobberSettings { Root = root })
.OfType()
+ .Where(_ => !IsInlineStaging(_))
.Select(_ => ParsedName.Parse(_, marker));
+ // The received text Verify stages for an inline snapshot is named like any other received file,
+ // but the snapshot it belongs to lives in a source file. Accepting it as a file snapshot would
+ // rename it to a verified file nothing reads, and leave the real snapshot pending.
+ private static bool IsInlineStaging(FilePath path) =>
+ path.FullPath.Contains(
+ $"/{InlineSnapshotFinder.StagingDirectoryName}/",
+ StringComparison.OrdinalIgnoreCase);
+
private (FilePath VerifiedPath, bool IsRerouted) GetVerified(
ParsedName received,
ReceivedMaps maps,
diff --git a/src/Verify.Terminal/SnapshotLocator.cs b/src/Verify.Terminal/SnapshotLocator.cs
new file mode 100644
index 0000000..5015c9b
--- /dev/null
+++ b/src/Verify.Terminal/SnapshotLocator.cs
@@ -0,0 +1,33 @@
+namespace Verify.Terminal;
+
+///
+/// Every snapshot a test run left pending under a directory, whichever form it takes.
+///
+///
+/// The two kinds are found in completely different ways — one by globbing `.received.` files, the
+/// other by asking the process holding the inline queue — but a review or an accept treats them the
+/// same, so the commands are handed one list rather than the seam between them.
+///
+public sealed class SnapshotLocator
+{
+ private readonly SnapshotFinder _snapshotFinder;
+ private readonly InlineSnapshotFinder _inlineSnapshotFinder;
+
+ public SnapshotLocator(
+ SnapshotFinder snapshotFinder,
+ InlineSnapshotFinder inlineSnapshotFinder)
+ {
+ _snapshotFinder = snapshotFinder.NotNull();
+ _inlineSnapshotFinder = inlineSnapshotFinder.NotNull();
+ }
+
+ ///
+ /// File snapshots first, then inline ones, so a review stays in a stable order rather than
+ /// interleaving two unrelated scans.
+ ///
+ public IReadOnlyList Find(DirectoryPath? root = null) =>
+ [
+ .. _snapshotFinder.Find(root),
+ .. _inlineSnapshotFinder.Find(root),
+ ];
+}
diff --git a/src/Verify.Terminal/SnapshotManager.cs b/src/Verify.Terminal/SnapshotManager.cs
index 127d3fc..ab2b0c2 100644
--- a/src/Verify.Terminal/SnapshotManager.cs
+++ b/src/Verify.Terminal/SnapshotManager.cs
@@ -3,13 +3,15 @@ namespace Verify.Terminal;
public sealed class SnapshotManager
{
private readonly IFileSystem _fileSystem;
+ private readonly InlineSnapshotManager _inline;
- public SnapshotManager(IFileSystem fileSystem)
+ public SnapshotManager(IFileSystem fileSystem, InlineSnapshotManager inline)
{
- _fileSystem = fileSystem;
+ _fileSystem = fileSystem.NotNull();
+ _inline = inline.NotNull();
}
- public bool Process(Snapshot snapshot, SnapshotAction action)
+ public SnapshotResult Process(ISnapshot snapshot, SnapshotAction action)
{
return action switch
{
@@ -19,7 +21,29 @@ public bool Process(Snapshot snapshot, SnapshotAction action)
};
}
- public bool Accept(Snapshot snapshot)
+ public SnapshotResult Accept(ISnapshot snapshot)
+ {
+ return snapshot.NotNull() switch
+ {
+ Snapshot file => AcceptFile(file),
+ // An inline snapshot lives in a source file, so accepting rewrites a literal rather
+ // than moving a file, and is usually done by whichever process holds the snapshot.
+ InlineSnapshot inline => _inline.Accept(inline),
+ _ => throw UnknownType(snapshot),
+ };
+ }
+
+ public SnapshotResult Reject(ISnapshot snapshot)
+ {
+ return snapshot.NotNull() switch
+ {
+ Snapshot file => RejectFile(file),
+ InlineSnapshot inline => _inline.Reject(inline),
+ _ => throw UnknownType(snapshot),
+ };
+ }
+
+ private SnapshotResult AcceptFile(Snapshot snapshot)
{
try
{
@@ -30,7 +54,7 @@ public bool Accept(Snapshot snapshot)
if (_fileSystem.File.Exists(snapshot.Verified))
{
// Could not delete the file
- return false;
+ return SnapshotResult.Failure();
}
}
@@ -39,13 +63,13 @@ public bool Accept(Snapshot snapshot)
}
catch
{
- return false;
+ return SnapshotResult.Failure();
}
- return true;
+ return SnapshotResult.Success;
}
- public bool Reject(Snapshot snapshot)
+ private SnapshotResult RejectFile(Snapshot snapshot)
{
try
{
@@ -56,15 +80,18 @@ public bool Reject(Snapshot snapshot)
if (_fileSystem.File.Exists(snapshot.Received))
{
// Could not delete the file
- return false;
+ return SnapshotResult.Failure();
}
}
}
catch
{
- return false;
+ return SnapshotResult.Failure();
}
- return true;
+ return SnapshotResult.Success;
}
+
+ private static InvalidOperationException UnknownType(ISnapshot snapshot) =>
+ new($"Unknown snapshot type: {snapshot.GetType().Name}");
}
diff --git a/src/Verify.Terminal/SnapshotRenderer.cs b/src/Verify.Terminal/SnapshotRenderer.cs
index 465607e..0d61914 100644
--- a/src/Verify.Terminal/SnapshotRenderer.cs
+++ b/src/Verify.Terminal/SnapshotRenderer.cs
@@ -35,18 +35,24 @@ public IRenderable Render(SnapshotDiff diff, int contextLines)
var marginWidth = (ctx.LineNumberWidth * 2) + 8 + 1;
var lineNumberWidth = (int)(Math.Log10(diff.New.Count) + 1);
- // Filename
+ // Header
ctx.Builder.AppendRepeated(Character.HorizontalLine, _console.Profile.Width);
ctx.Builder.CommitLine();
- ctx.Builder.AppendInlineRenderable(new TextPath(diff.Snapshot.Received.GetFilename().FullPath));
- if (diff.Snapshot.IsRerouted)
+ foreach (var (_, first, _, header) in diff.Snapshot.Headers.Enumerate())
{
- ctx.Builder.CommitLine();
- ctx.Builder.AppendInlineRenderable(new TextPath(diff.Snapshot.Verified.GetFilename().FullPath));
+ if (!first)
+ {
+ ctx.Builder.CommitLine();
+ }
- ctx.Builder.AppendSpace();
- ctx.Builder.Append("(rerouted)", Color.Yellow);
+ ctx.Builder.AppendInlineRenderable(new TextPath(header.Path));
+
+ if (header.Note != null)
+ {
+ ctx.Builder.AppendSpace();
+ ctx.Builder.Append(header.Note, Color.Yellow);
+ }
}
ctx.Builder.CommitLine();
diff --git a/src/Verify.Terminal/SnapshotResult.cs b/src/Verify.Terminal/SnapshotResult.cs
new file mode 100644
index 0000000..991405d
--- /dev/null
+++ b/src/Verify.Terminal/SnapshotResult.cs
@@ -0,0 +1,30 @@
+namespace Verify.Terminal;
+
+///
+/// What became of an accept or a reject.
+///
+///
+/// A result rather than a bool, because an inline snapshot can refuse for reasons the caller
+/// cannot work out from the snapshot itself: the source moved since the test ran, the frameworks
+/// disagreed, or the process holding the snapshot had something to say about it.
+///
+public sealed class SnapshotResult
+{
+ private SnapshotResult(bool succeeded, string? message)
+ {
+ Succeeded = succeeded;
+ Message = message;
+ }
+
+ public bool Succeeded { get; }
+
+ ///
+ /// What to tell the user about a failure, and null when there is nothing to add beyond the
+ /// failure itself.
+ ///
+ public string? Message { get; }
+
+ public static readonly SnapshotResult Success = new(true, null);
+
+ public static SnapshotResult Failure(string? message = null) => new(false, message);
+}
diff --git a/src/Verify.Terminal/Verify.Terminal.csproj b/src/Verify.Terminal/Verify.Terminal.csproj
index cd80d89..2caff0c 100644
--- a/src/Verify.Terminal/Verify.Terminal.csproj
+++ b/src/Verify.Terminal/Verify.Terminal.csproj
@@ -13,6 +13,7 @@
+