Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br>the inline queue?"}
Owner -->|yes| Queued["Read from the queue.<br>Accepting asks the owner to apply it"]
Owner -->|no| Staged["Read from obj/VerifyInline/.<br>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
Expand Down
8 changes: 5 additions & 3 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="DiffEngine" Version="20.0.0-beta.38" />
<PackageVersion Include="DiffPlex" Version="1.9.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Spectre.Console" Version="0.57.2" />
Expand All @@ -13,10 +14,11 @@
<PackageVersion Include="Spectre.Console.Testing" Version="0.57.2" />
<PackageVersion Include="Spectre.IO.Testing" Version="0.23.0" />
<PackageVersion Include="Spectre.Verify.Extensions" Version="28.16.0" />
<PackageVersion Include="Verify.ExceptionParsing" Version="31.28.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.28.0" />
<PackageVersion Include="Verify.ExceptionParsing" Version="32.0.0-beta.16" />
<PackageVersion Include="Verify" Version="32.0.0-beta.16" />
<PackageVersion Include="Verify.XunitV3" Version="32.0.0-beta.16" />
<PackageVersion Include="xunit.v3" Version="4.0.0" />
<PackageVersion Include="MinVer" PrivateAssets="All" Version="7.0.0" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.16.1" />
<PackageVersion Include="Roslynator.Analyzers" Version="5.0.0" />
</ItemGroup>
</Project>
40 changes: 40 additions & 0 deletions src/Shared/DeadInlineQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace Verify.Terminal.Testing;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class DeadInlineQueue
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
2 changes: 2 additions & 0 deletions src/Verify.Terminal.IntegrationTests/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
160 changes: 154 additions & 6 deletions src/Verify.Terminal.IntegrationTests/Harness.cs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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++;
}
}
Expand All @@ -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<string> 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<string> ReceivedFileNames() =>
System.IO.Directory
Expand All @@ -73,20 +193,48 @@ public IReadOnlyList<string> ReceivedFileNames() =>
.ToList();

// Runs the real SnapshotFinder (real globber, real filesystem) over the temp directory.
public Snapshot FindSingle()
public Snapshot FindSingle() => FindFileSnapshots().Single();

public ISet<Snapshot> 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);
Expand Down
Loading