DiffEngineTray sits in the notification area on Windows, the menu bar on macOS, and the panel's tray on Linux. For supported snapshot testing libraries, it monitors pending changes in snapshots, and provides a mechanism for accepting those changes. It is intended as an alternative to using the clipboard as an approval mechanism.
dotnet tool install -g DiffEngineTray
The same command on Windows, macOS and Linux, x64 or Arm64. DiffEngineTray only names a package for each platform, and the one for the machine's is installed with it: DiffEngineTray.win-x64, DiffEngineTray.osx-arm64, DiffEngineTray.linux-x64 and so on. Each carries that platform's tray and a viewer that reads documents.
Run diffenginetray in a console to start the app. To have it start at login, use "Run at startup": in the Options window on Windows, and on the menu on macOS and Linux.
Moves, deletes and pending snapshots are grouped by the containing solution. In the above, the files exist in DiffEngine, so they are grouped under it. The per-group headers act on that group only: clicking one solution's "Pending Snapshots" does not accept another's.
"Pending Moves" will accept the changes to file3 and file4.
Each of "file3" and "file4" opens a submenu of actions for that change: accept it, discard it, open the diff tool on it, and open its directory.
A test can produce multiple resulting snapshots. If the accepted versions has a different number of snapshots to the current test run, then some of those snapshots need to be deleted. The delete functionality in the tray tool handles this scenario.
"Pending Deletes" will delete file1 and file2.
Each of "file1" and "file2" opens a submenu with "Accept delete", which deletes that file, and "Open directory".
A delete is withdrawn when a later test run verifies against its file again, since the file is then in use rather than stale: DiffRunner.SettleDelete(file) drops the pending delete and leaves the file alone. It reaches a tray that owns the inline queue, which is the usual arrangement since the tray starts at login. A pending move onto the file withdraws the delete as well, whichever process owns the queue: a run that produced a received file for it is a run that verified against it.
Inline snapshots are reviewed in DiffEngineViewer rather than in a diff tool. The queue of them lives in whichever process claims the loopback port first, and stays there for as long as that process runs. With no tray, that is the viewer. With one, it is normally the tray, because the tray starts at login and the viewer only starts when a snapshot fails.
When the tray holds it, the window becomes disposable. A viewer that is closed, killed or crashes takes nothing pending with it, and the tray opens a new one on the same queue. A snapshot arriving with no window open starts one.
The viewer installed with the tray also reads documents: PDF, Word, Excel and PowerPoint files as text, as their pages drawn, or both, and SVGs and maps drawn beside their text. The copy bundled in the DiffEngine package does not. On a machine with the tray installed, test runs resolve the tray's copy ahead of the bundled one, so pending documents open in a viewer that can read them.
"Pending Snapshots" accepts all of them. Each one opens a submenu that accepts it, discards it, opens the viewer on it, and opens the source file. A snapshot that failed to apply is marked with ! and stays pending, so it can be retried once whatever blocked it is out of the way.
Exiting the tray writes any still-pending inline snapshots back to disk, under the source project's obj/VerifyInline/, where accept tooling such as Verify.Terminal still finds them. Logging off or shutting down does the same, and a snapshot that arrives once the session is ending is refused, so the test run stages it itself. A kill or a crash skips that, and loses the queue as it loses pending moves and deletes; re-run the tests.
"Accept all" will accept all pending moves, deletes and inline snapshots. Snapshots whose target frameworks disagree about the content are skipped rather than picked between; resolve those in the viewer.
The deletes it carries out are the ones that were pending when it began. A delete can be the last copy of a snapshot that is moving inline, so the deletes are held back, and the tray says so, when a snapshot could not be written or when a viewer that owns the queue did not answer. A delete of a file that an accepted move has written is left pending rather than carried out, and stays that way through later "Accept all"s: it is marked ~ in the menu, with the reason. It goes when it is accepted on its own, or discarded and raised by a later run. A test run raising it again does not release it unless the file has changed since the move wrote it.
A long queue takes a while to accept. An open DiffEngineViewer window shows how far it has got, with each snapshot leaving the list as it lands.
Windows only. A lock elsewhere is advisory and keeps nothing from replacing the file, so there is nothing to resolve.
If accepting a move fails because the files are locked by another process (for example the snapshot is open in Microsoft Word), a prompt is shown listing the locked files and the locking processes:
- "Ignore" leaves the move pending so it can be accepted later.
- "Kill [process] and accept" kills the locking processes and accepts the move.
- "Kill and accept all pending" kills the locking processes and accepts all pending moves, killing any other locking processes without further prompts.
- "Always kill" kills the locking processes and accepts the move. The choice is stored in settings, so future locked files are killed without prompting. It can be toggled in the Options dialog.
Discard will clear all currently tracked items. It is the same discard as the per item menu and as the one in DiffEngineViewer: a move loses its received file, a pending delete keeps its file and is untracked, and every pending inline snapshot is dropped.
Prompts for a directory, says how many *.verified.* and *.received.* files are under it, and once that is confirmed deletes them all.
On Windows a file that cannot be deleted is asked about, with a choice to retry it, skip it or stop. On macOS and Linux the questions are put by the desktop's own programs: AppleScript's dialogs on macOS, and on Linux zenity's, or kdialog's on KDE. Either one is used where only one is installed, and with neither the item is not on the menu. A file that cannot be deleted there is skipped, and a notification says how many were deleted and what stopped the rest.
Everything currently tracked, as text: every field of every pending move, delete and snapshot, plus which process owns the inline queue.
The menu shows each pending item reduced to what fits on a line, a name and a few actions. This is the rest of it, for when the interesting part is a path, an argument list, or the process a diff tool was launched as. "Copy" puts the whole report on the clipboard, which is what to attach to an issue.
Nothing pushes at the window, so it shows the moment it was read. "Refresh" takes a newer reading.
On macOS and Linux there is no window: the same report is written to debug.txt in the logging directory and opened with whatever the desktop opens a text file with. Choosing "Debug view" again writes a newer one.
Updates the tray to the latest version on NuGet, prereleases included. On Windows it is the "Update" button in the Options window, and on macOS and Linux an "Update" item on the menu. It is offered only for a tray installed as a .NET tool, which a build's output is not.
The tray exits, and a shell it started then:
- waits for the tray to be gone;
- runs
dotnet tool update DiffEngineTray --tool-path {folder} --prerelease, where the folder is the one holding thediffenginetraycommand the tray was started as, so a global install and one made with--tool-pathare both updated where they are; - starts the tray again.
The tray it starts says once how the update went. The dotnet used is the one the tray runs on, since a tray started at login may have none on its PATH.
On Windows, anything still running from the tray's install is stopped first, since a file in use cannot be replaced. That includes a viewer window the tray opened. A viewer from there that holds the snapshot queue is asked to close before the tray exits, and stages what is pending in it as files under each source project's obj/VerifyInline, as any viewer does on exit, where an IDE plugin or a text diff tool can still review them.
Windows only. On macOS and Linux the menu has "Run at startup", and the rest is set otherwise or not there.
Runs DiffEngineTray at system startup.
By default, when a diff is opened, the temp file will be on the left and the target file will be on the right. To invert this, select "Open on left".
Control the max instances to launch setting.
When accepting a move with locked files, kill the locking processes without prompting.
Registers a system wide HotKey to discard pending:
- Deletes
- Moves
- Inline snapshots
Registers a system wide HotKey to accept pending:
- Deletes
- Moves
- Inline snapshots (conflicted ones are skipped)
Registers a system wide HotKey to accept pending:
- Deletes
- Moves that are currently open in a diff tool. A pair whose tool is the viewer counts: it is drawn as a row in the one window every pending pair shares, rather than in a process of its own
- Inline snapshots, all of which are open by definition: the viewer only stays running while it has something to show
To limit impact on system resources, the default max concurrent open tool instances is limited to 5.
Accept all open HotKey allows the current batch of open diffs to be accepted.
The tray does the same there: it tracks pending moves and deletes, holds the inline snapshot queue, and lists all of it in a menu grouped by solution, with the same actions on each item and the same "Accept all" and "Discard". The menu's other items are:
- Exit
- Run at startup
- Update
- Debug view
- Purge verified files, on Linux where zenity or kdialog is installed
- Open logs
- Raise issue
On macOS the icon is in the menu bar, and the tray has no Dock icon. On Linux the icon and menu are a StatusNotifierItem, which KDE Plasma and most other panels show. GNOME shows one only with an AppIndicator extension, which Ubuntu's GNOME has and a stock GNOME does not. With nowhere to show its menu the tray runs anyway, holding the queue and tracking what test runs send it, and says so once. The viewer still shows everything it holds.
What a balloon says on Windows is a desktop notification on both.
"Run at startup" is an autostart entry, ~/.config/autostart/diffenginetray.desktop on Linux (under XDG_CONFIG_HOME where that is set), and a launch agent, ~/Library/LaunchAgents/org.verifytests.diffenginetray.plist, on macOS. Either starts the diffenginetray command the tray was started as.
What is not there:
- The Options window. "Open on left" and "Max instances to launch" are environment variables, which the window sets for the user on Windows. Elsewhere set
DiffEngine_TargetOnLeftorDiffEngine_MaxInstancesin the environment test runs start in. - The HotKeys, which are registered with Windows.
- The locked files prompt, which has nothing to resolve.
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.
- ApprovalTests v5.4.0 and above
- Shouldly v4.0.0 and above
- Verify v6.10.4 and above
{
"Type":"Move",
"Temp":"theTempFilePath",
"Target":"theTargetFilePath",
"CanKill":true,
"Exe":"theExePath",
"Arguments":"TheArguments",
"ProcessId":1000
}{
"Type":"Delete",
"File":"theFilePath"
}A move or a delete of a file that was derived from a document names the temp file of the pending move it was derived from, in a Source property that is absent otherwise:
{
"Type":"Move",
"Temp":"theTempFilePath",
"Target":"theTargetFilePath",
"CanKill":true,
"Exe":"theExePath",
"Arguments":"TheArguments",
"ProcessId":1000,
"Source":"theSourceTempFilePath"
}{
"Type":"Delete",
"File":"theFilePath",
"Source":"theSourceTempFilePath"
}The tray tracks such a file as it tracks any other, so it is listed in the menu and taken by an accept-all. The property is only passed on to the viewer, which is what folds the file beneath its document. A tray older than the property ignores it.
Beside the installed tool, in the package for the machine's runtime identifier, so it moves with the target framework the tray is built for:
%UserProfile%\.dotnet\tools\.store\diffenginetray\{VERSION}\diffenginetray.{RID}\{VERSION}\tools\net10.0\{RID}\logs
For example:
C:\Users\simon\.dotnet\tools\.store\diffenginetray\21.0.0\diffenginetray.win-x64\21.0.0\tools\net10.0\win-x64\logs
On macOS and Linux the same, under the home directory:
~/.dotnet/tools/.store/diffenginetray/{VERSION}/diffenginetray.{RID}/{VERSION}/tools/net10.0/{RID}/logs
Up to 20.8.2 the tray was one package for every architecture, and logged to ...\diffenginetray\{VERSION}\diffenginetray\{VERSION}\tools\net10.0\any\logs.
The menu's "Open logs" opens it without any of that.



