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
1 change: 1 addition & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,5 @@ apart.
- Tool discovery uses wildcard path matching (`WildcardFileFinder`) to find executables in common install locations
- Tool order can be customized via `DiffEngine_ToolOrder` environment variable
- `DisabledChecker` respects `DiffEngine_Disabled` env var
- `TrayDisabledChecker` respects `DiffEngine_TrayDisabled` env var, behind `DiffRunner.TrayDisabled`. Separate from `Disabled` because tracking a pending move is separate from launching a tool: every exit of `InnerLaunch`, `Disabled` included, still calls `AddMove`. `PendingFiles.TrayAvailable` is the single gate
- Tests use TUnit and Verify for snapshot testing
13 changes: 13 additions & 0 deletions docs/mdsource/tray.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@ To limit impact on system resources, the [default max concurrent open tool insta
Accept all open HotKey allows the current batch of open diffs to be accepted.


## Opting out of tracking

Pending moves and deletes are sent to a running tray by the DiffEngine library inside the test process. That tracking is separate from launching a diff tool: every exit from `DiffRunner.Launch` adds the move, `DiffRunner.Disabled` included, so turning diff off does not turn it off.

To opt a process out, set an environment variable `DiffEngine_TrayDisabled` with the value `true`, or in code:

```
DiffRunner.TrayDisabled = true;
```

The case it exists for is a test suite that needs the launch to happen but does not want what it produces collected: for example a suite asserting on the files a snapshot library stages, where each run would otherwise leave the tray a pending entry pointing at a throwaway directory. A move with no tray falls through to whatever owns the inline queue, and goes nowhere when nothing does.


## Currently supported in

* [ApprovalTests](https://github.com/approvals/ApprovalTests.Net) v5.4.0 and above
Expand Down
13 changes: 13 additions & 0 deletions docs/tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ To limit impact on system resources, the [default max concurrent open tool insta
Accept all open HotKey allows the current batch of open diffs to be accepted.


## Opting out of tracking

Pending moves and deletes are sent to a running tray by the DiffEngine library inside the test process. That tracking is separate from launching a diff tool: every exit from `DiffRunner.Launch` adds the move, `DiffRunner.Disabled` included, so turning diff off does not turn it off.

To opt a process out, set an environment variable `DiffEngine_TrayDisabled` with the value `true`, or in code:

```
DiffRunner.TrayDisabled = true;
```

The case it exists for is a test suite that needs the launch to happen but does not want what it produces collected: for example a suite asserting on the files a snapshot library stages, where each run would otherwise leave the tray a pending entry pointing at a throwaway directory. A move with no tray falls through to whatever owns the inline queue, and goes nowhere when nothing does.


## Currently supported in

* [ApprovalTests](https://github.com/approvals/ApprovalTests.Net) v5.4.0 and above
Expand Down
14 changes: 14 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ DiffEngine manages launching and cleanup of diff tools. It is designed to be use
* [Programmatic usage](#programmatic-usage)
* [Disable for a machine/process](#disable-for-a-machineprocess)
* [Disable in code](#disable-in-code)
* [Disable the tray](#disable-the-tray)
* [Icons](#icons)<!-- endToc -->
* [Tools](/docs/diff-tool.md)<!-- include: doc-index. path: /docs/mdsource/doc-index.include.md -->
* [Tool Order](/docs/diff-tool.order.md)
Expand Down Expand Up @@ -259,6 +260,19 @@ DiffRunner.Disabled = true;
```


## Disable the tray

Pending moves and deletes are sent to [DiffEngineTray](/docs/tray.md) when one is running. That tracking is separate from launching a diff tool, so disabling diff does not stop it.

Set an environment variable `DiffEngine_TrayDisabled` with the value `true`, or in code:

```
DiffRunner.TrayDisabled = true;
```

[More detail](/docs/tray.md#opting-out-of-tracking).


## Icons

[Game](https://thenounproject.com/term/game/2956486/) designed by [Andrejs Kirma](https://thenounproject.com/andrejs/) from [The Noun Project](https://thenounproject.com).
Expand Down
13 changes: 13 additions & 0 deletions readme.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ DiffRunner.Disabled = true;
```


## Disable the tray

Pending moves and deletes are sent to [DiffEngineTray](/docs/tray.md) when one is running. That tracking is separate from launching a diff tool, so disabling diff does not stop it.

Set an environment variable `DiffEngine_TrayDisabled` with the value `true`, or in code:

```
DiffRunner.TrayDisabled = true;
```

[More detail](/docs/tray.md#opting-out-of-tracking).


## Icons

[Game](https://thenounproject.com/term/game/2956486/) designed by [Andrejs Kirma](https://thenounproject.com/andrejs/) from [The Noun Project](https://thenounproject.com).
Expand Down
189 changes: 189 additions & 0 deletions src/DiffEngine.Tests/TrayDisabledTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// DiffEngineTray is the obsolete public shim, but its IsRunning is still where the tray check
// lives, and this test has to move it.
#pragma warning disable CS0618

/// <summary>
/// <see cref="DiffRunner.TrayDisabled" />: a process that wants a diff tool launched but does not
/// want the tray collecting what it produces.
/// <para>
/// The case it exists for is a test suite driving a library that stages snapshots. Turning diff off
/// is not the same switch: in Verify it also turns off the inline staging such a suite exists to
/// test, and it does not stop the tracking anyway, since every exit of
/// <c>DiffRunner.InnerLaunch</c> - <c>Disabled</c> included - still adds the move. So a developer
/// box collected a pending move per snapshot per run, each pointing at a throwaway directory, and
/// each offering an accept that would write to it.
/// </para>
/// </summary>
[NotInParallel]
public class TrayDisabledTests
{
const string Variable = "DiffEngine_TrayDisabled";

[Test]
public async Task Read_from_the_environment_until_set()
{
DiffRunner.ResetTrayDisabled();

Environment.SetEnvironmentVariable(Variable, "true");
await Assert.That(DiffRunner.TrayDisabled).IsTrue();

// Setting pins it, exactly as Disabled does, so a consumer that opts back in is not
// overruled by the machine it runs on.
DiffRunner.TrayDisabled = false;
await Assert.That(DiffRunner.TrayDisabled).IsFalse();
}

[Test]
public async Task A_disabled_tray_leaves_the_move_to_the_queue_owner()
{
await Assert.That(ViewerServer.TryBind(0, out var bound)).IsTrue();
using var server = bound!;
using var cancel = new CancelSource();

var heardByOwner = new ConcurrentBag<string>();
var listening = server.Listen(
_ =>
{
heardByOwner.Add($"{_.Verb}:{_.Key}");
return ViewerResponse.Success();
},
cancel.Token);

using var tray = new PiperListener();

var previousPort = PiperClient.Port;
var previousViewerPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable);
var previousRunning = DiffEngineTray.IsRunning;
try
{
// A tray that is running and really would take it, so what follows is the switch
// rather than an absent tray.
PiperClient.Port = tray.Port;
DiffEngineTray.IsRunning = true;
Environment.SetEnvironmentVariable(ViewerClient.PortVariable, server.Port.ToString());

DiffRunner.TrayDisabled = false;
await PendingFiles.AddMoveAsync("taken.txt", "target.txt", null, null, false, null, cancel.Token);

await tray.WaitFor(1);
await Assert.That(heardByOwner.Count).IsEqualTo(0);

DiffRunner.TrayDisabled = true;
await PendingFiles.AddMoveAsync("skipped.txt", "target.txt", null, null, false, null, cancel.Token);

// The owner took the second one, which is the fallback branch for no tray at all.
await Assert.That(heardByOwner.Count).IsEqualTo(1);
await Assert.That(heardByOwner).Contains(_ => _.StartsWith("Move:", StringComparison.Ordinal));

// And the tray still holds only the first. Asserted after the owner heard the second,
// because the piper decision is made before that send, so by here it has happened.
await Assert.That(tray.Payloads.Count).IsEqualTo(1);
await Assert.That(tray.Payloads).Contains(_ => _.Contains("taken.txt", StringComparison.Ordinal));
}
finally
{
PiperClient.Port = previousPort;
DiffEngineTray.IsRunning = previousRunning;
Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousViewerPort);
await cancel.CancelAsync();
try
{
// No token: the line above already cancelled it, so passing it here would return
// before the listener had unwound rather than waiting for it to. The timeout is
// what bounds the drain
// ReSharper disable once MethodSupportsCancellation
await listening.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (Exception exception)
when (exception is OperationCanceledException or TimeoutException)
{
}
}
}

/// <summary>
/// Every other test in this assembly runs with the ambient value, which the module initializer
/// leaves alone.
/// </summary>
[After(Test)]
public void Restore()
{
Environment.SetEnvironmentVariable(Variable, null);
DiffRunner.ResetTrayDisabled();
}

/// <summary>
/// Stands in for the tray's PiperServer: the payload is written one way and never answered, so
/// accepting the connection and reading it to the end is the whole protocol from this side.
/// </summary>
sealed class PiperListener : IDisposable
{
readonly TcpListener listener;
readonly CancelSource cancellation = new();
readonly Task loop;

public PiperListener()
{
listener = new(IPAddress.Loopback, 0);
listener.Start();
Port = ((IPEndPoint) listener.LocalEndpoint).Port;
loop = Task.Run(Accept);
}

public int Port { get; }

public ConcurrentBag<string> Payloads { get; } = [];

public async Task WaitFor(int count)
{
for (var attempt = 0; attempt < 250; attempt++)
{
if (Payloads.Count >= count)
{
return;
}

await Task.Delay(20);
}

throw new($"Only {Payloads.Count} payloads reached the tray, expected {count}.");
}

async Task Accept()
{
while (!cancellation.IsCancellationRequested)
{
try
{
// No token: the cancellable overload is net6 and up, and this compiles for
// net48 too. Stop in Dispose is what breaks the accept, which lands in the
// catch below.
using var client = await listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
using var reader = new StreamReader(stream);
Payloads.Add(await reader.ReadToEndAsync());
}
catch (Exception exception)
when (exception is OperationCanceledException or ObjectDisposedException or SocketException)
{
return;
}
}
}

public void Dispose()
{
cancellation.Cancel();
listener.Stop();
try
{
loop.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
}

cancellation.Dispose();
}
}
}
35 changes: 35 additions & 0 deletions src/DiffEngine/DiffRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ public static bool Disabled
internal static void ResetDisabled() =>
disabled = null;

/// <summary>
/// Whether pending moves and deletes are sent to DiffEngineTray.
/// <para>
/// Independent of <see cref="Disabled" />, because the two answer different questions and a
/// test suite driving a library that stages snapshots needs them apart. Disabling diff turns
/// off the launch, and in Verify it also turns off the inline staging that a suite testing
/// that staging exists to produce. This turns off only the tracking, so a machine with a tray
/// running does not collect a pending move per snapshot a test run happened to produce -
/// pointing at a throwaway directory, and offering an accept that would write to it.
/// </para>
/// <para>
/// A move with no tray falls through to the inline queue owner, and goes nowhere when nothing
/// owns it. Pair this with a <c>DiffEngine_ViewerPort</c> nothing is listening on to detach
/// from both, which is what <c>DiffEngine.Tests</c> does.
/// </para>
/// <para>
/// Read from <c>DiffEngine_TrayDisabled</c> until set, then pinned, exactly as
/// <see cref="Disabled" /> is.
/// </para>
/// </summary>
public static bool TrayDisabled
{
get => trayDisabled ?? TrayDisabledChecker.IsDisabled();
set => trayDisabled = value;
}

static bool? trayDisabled;

/// <summary>
/// Forgets an explicit <see cref="TrayDisabled" />, so it is read from the environment again.
/// For tests, which is where anything sets it and then wants the ambient value back.
/// </summary>
internal static void ResetTrayDisabled() =>
trayDisabled = null;

public static void MaxInstancesToLaunch(int value) =>
MaxInstance.SetForAppDomain(value);

Expand Down
Loading
Loading